From 155b59d71ff4013cb65e122cf1ef25d220096732 Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Wed, 28 Jan 2026 13:33:25 -0500 Subject: [PATCH] Move clearable text field to core module. --- .../securesms/nicknames/NicknameActivity.kt | 140 +-------- .../core/ui/compose/ClearableTextField.kt | 276 ++++++++++++++++++ .../org/signal/core/ui/compose/SignalIcons.kt | 3 +- .../res/drawable/symbol_x_circle_fill_24.xml | 0 4 files changed, 281 insertions(+), 138 deletions(-) create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt rename {app => core/ui}/src/main/res/drawable/symbol_x_circle_fill_24.xml (100%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt index e7f36c9097..3700ba23d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/nicknames/NicknameActivity.kt @@ -10,7 +10,6 @@ import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContract -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -20,12 +19,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -36,21 +32,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ClearableTextField import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalIcons -import org.signal.core.ui.compose.TextFields import org.signal.core.util.getParcelableCompat import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R @@ -292,7 +286,8 @@ private fun NicknameContent( onValueChange = callback::onNoteChanged, keyboardActions = KeyboardActions.Default, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), - charactersRemaining = state.noteCharactersRemaining, + charactersRemainingBeforeLimit = state.noteCharactersRemaining, + countdownConfig = ClearableTextField.CountdownConfig(displayThreshold = 100, warnThreshold = 5), modifier = Modifier .focusRequester(noteFocusRequester) .fillMaxWidth() @@ -357,132 +352,3 @@ private fun NicknameContent( } } } - -@DayNightPreviews -@Composable -private fun ClearableTextFieldPreview() { - Previews.Preview { - val focusRequester = remember { FocusRequester() } - - Column(modifier = Modifier.padding(16.dp)) { - ClearableTextField( - value = "", - hint = "Without content", - enabled = true, - onValueChange = {}, - clearContentDescription = "" - ) - Spacer(modifier = Modifier.size(16.dp)) - ClearableTextField( - value = "Test", - hint = "With Content", - enabled = true, - onValueChange = {}, - clearContentDescription = "" - ) - Spacer(modifier = Modifier.size(16.dp)) - ClearableTextField( - value = "", - hint = "Disabled", - enabled = false, - onValueChange = {}, - clearContentDescription = "" - ) - Spacer(modifier = Modifier.size(16.dp)) - ClearableTextField( - value = "", - hint = "Focused", - enabled = true, - onValueChange = {}, - modifier = Modifier.focusRequester(focusRequester), - clearContentDescription = "" - ) - } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - } -} - -@Composable -private fun ClearableTextField( - value: String, - hint: String, - clearContentDescription: String, - enabled: Boolean, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - singleLine: Boolean = false, - clearable: Boolean = true, - charactersRemaining: Int = Int.MAX_VALUE, - keyboardActions: KeyboardActions = KeyboardActions.Default, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default -) { - var focused by remember { mutableStateOf(false) } - - val displayCountdown = charactersRemaining <= 100 - - val clearButton: @Composable () -> Unit = { - ClearButton( - visible = focused, - onClick = { onValueChange("") }, - contentDescription = clearContentDescription - ) - } - - Box(modifier = modifier) { - TextFields.TextField( - value = value, - onValueChange = onValueChange, - label = { - Text(text = hint) - }, - enabled = enabled, - singleLine = singleLine, - keyboardActions = keyboardActions, - keyboardOptions = keyboardOptions, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { focused = it.hasFocus && clearable }, - colors = TextFieldDefaults.colors( - unfocusedLabelColor = MaterialTheme.colorScheme.outline, - unfocusedIndicatorColor = MaterialTheme.colorScheme.outline - ), - trailingIcon = if (clearable) clearButton else null, - contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp) - ) - - AnimatedVisibility( - visible = displayCountdown, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 10.dp, end = 12.dp) - ) { - Text( - text = "$charactersRemaining", - style = MaterialTheme.typography.bodySmall, - color = if (charactersRemaining <= 5) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline - ) - } - } -} - -@Composable -private fun ClearButton( - visible: Boolean, - onClick: () -> Unit, - contentDescription: String -) { - AnimatedVisibility(visible = visible) { - IconButton( - onClick = onClick - ) { - Icon( - painter = painterResource(id = R.drawable.symbol_x_circle_fill_24), - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.outline - ) - } - } -} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt new file mode 100644 index 0000000000..f3ab32a48c --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ClearableTextField.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +object ClearableTextField { + /** + * Configures how the "characters remaining" countdown is displayed. + */ + data class CountdownConfig( + /** The number of characters remaining before the countdown is displayed. */ + val displayThreshold: Int, + + /** The number of characters remaining before the countdown is displayed as warning. */ + val warnThreshold: Int + ) +} + +/** + * A text field with an optional clear button that appears when focused and [clearable] is true. + * + * Also supports displaying a character countdown when the character count is approaching a limit. + */ +@Composable +fun ClearableTextField( + value: String, + onValueChange: (String) -> Unit, + hint: String, + clearContentDescription: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + leadingIcon: @Composable (() -> Unit)? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + clearable: Boolean = true, + charactersRemainingBeforeLimit: Int = Int.MAX_VALUE, + countdownConfig: ClearableTextField.CountdownConfig? = null, + colors: TextFieldColors = defaultTextFieldColors() +) { + ClearableTextField( + value = value, + onValueChange = onValueChange, + clearContentDescription = clearContentDescription, + modifier = modifier, + enabled = enabled, + textStyle = textStyle, + label = { Text(hint) }, + leadingIcon = leadingIcon, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + clearable = clearable, + charactersRemaining = charactersRemainingBeforeLimit, + countdownConfig = countdownConfig, + colors = colors + ) +} + +/** + * A text field with an optional clear button that appears when focused and [clearable] is true. + * + * Also supports displaying a character countdown when the character count is approaching a limit. + */ +@Composable +fun ClearableTextField( + value: String, + onValueChange: (String) -> Unit, + clearContentDescription: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + clearable: Boolean = true, + charactersRemaining: Int = Int.MAX_VALUE, + countdownConfig: ClearableTextField.CountdownConfig? = null, + colors: TextFieldColors = defaultTextFieldColors() +) { + var focused by remember { mutableStateOf(false) } + val displayCountdown = countdownConfig != null && charactersRemaining <= countdownConfig.displayThreshold + + val clearButton: @Composable () -> Unit = { + ClearButton( + visible = focused, + onClick = { onValueChange("") }, + contentDescription = clearContentDescription + ) + } + + Box(modifier = modifier) { + TextFields.TextField( + value = value, + onValueChange = onValueChange, + textStyle = textStyle, + label = label, + enabled = enabled, + singleLine = singleLine, + keyboardActions = keyboardActions, + keyboardOptions = keyboardOptions, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.hasFocus && clearable }, + colors = colors, + leadingIcon = leadingIcon, + trailingIcon = if (clearable) clearButton else null, + contentPadding = TextFieldDefaults.contentPaddingWithLabel(end = if (displayCountdown) 48.dp else 16.dp) + ) + + AnimatedVisibility( + visible = displayCountdown, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 10.dp, end = 12.dp) + ) { + val errorThresholdExceeded = countdownConfig != null && charactersRemaining <= countdownConfig.warnThreshold + Text( + text = "$charactersRemaining", + style = MaterialTheme.typography.bodySmall, + color = if (errorThresholdExceeded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.outline + ) + } + } +} + +@Composable +private fun ClearButton( + visible: Boolean, + onClick: () -> Unit, + contentDescription: String +) { + AnimatedVisibility(visible = visible) { + IconButton( + onClick = onClick + ) { + Icon( + painter = SignalIcons.XCircleFill.painter, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.outline + ) + } + } +} + +@Composable +private fun defaultTextFieldColors(): TextFieldColors = TextFieldDefaults.colors( + unfocusedLabelColor = MaterialTheme.colorScheme.outline, + unfocusedIndicatorColor = MaterialTheme.colorScheme.outline +) + +@DayNightPreviews +@Composable +private fun ClearableTextFieldPreview() { + Previews.Preview { + val focusRequester = remember { FocusRequester() } + + Column(modifier = Modifier.padding(16.dp)) { + ClearableTextField( + value = "", + onValueChange = {}, + hint = "without content", + clearContentDescription = "" + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ClearableTextField( + value = "foo bar", + onValueChange = {}, + hint = "with content", + clearContentDescription = "" + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ClearableTextField( + value = "", + onValueChange = {}, + hint = "disabled", + clearContentDescription = "", + enabled = false + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ClearableTextField( + value = "", + onValueChange = {}, + hint = "focused without content", + clearContentDescription = "", + modifier = Modifier.focusRequester(focusRequester) + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } +} + +@DayNightPreviews +@Composable +private fun ClearableTextFieldCharacterCountPreview() { + Previews.Preview { + val countdownConfig = ClearableTextField.CountdownConfig(displayThreshold = 100, warnThreshold = 10) + + Column(modifier = Modifier.padding(16.dp)) { + ClearableTextField( + value = "Character count normal state", + onValueChange = {}, + hint = "countdown shown", + clearContentDescription = "Clear", + charactersRemainingBeforeLimit = 50, + countdownConfig = countdownConfig + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ClearableTextField( + value = "Very long text showing the character count warning state", + onValueChange = {}, + hint = "countdown warning", + clearContentDescription = "Clear", + charactersRemainingBeforeLimit = 8, + countdownConfig = countdownConfig + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ClearableTextField( + value = "No character count shown", + onValueChange = {}, + hint = "no countdown shown", + clearContentDescription = "Clear", + charactersRemainingBeforeLimit = 150, + countdownConfig = countdownConfig + ) + } + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 7e46f500a4..7ce45d5508 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -52,7 +52,8 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { Settings(icon(R.drawable.symbol_settings_android_24)), Share(icon(R.drawable.symbol_share_android_24)), Trash(icon(R.drawable.symbol_trash_24)), - X(icon(R.drawable.symbol_x_24)) + X(icon(R.drawable.symbol_x_24)), + XCircleFill(icon(R.drawable.symbol_x_circle_fill_24)) } private fun icon(@DrawableRes id: Int) = SignalIcon.DrawableIcon(id) diff --git a/app/src/main/res/drawable/symbol_x_circle_fill_24.xml b/core/ui/src/main/res/drawable/symbol_x_circle_fill_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_x_circle_fill_24.xml rename to core/ui/src/main/res/drawable/symbol_x_circle_fill_24.xml