Fix label font weight on focused fields.

This commit is contained in:
Greyson Parrelli
2026-09-17 11:43:21 -04:00
parent 8370efcb25
commit 3e6b8397d6
9 changed files with 74 additions and 10 deletions
@@ -6,7 +6,9 @@
package org.signal.core.ui.compose
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.relocation.BringIntoViewRequester
@@ -20,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
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
@@ -38,6 +41,7 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@@ -188,6 +192,26 @@ object TextFields {
}
}
/**
* A text field label that sits at Label Medium weight once it has minimized above the input, which is what happens
* when the field is focused or already has text in it.
*
* [interactionSource] must be the same instance given to the text field.
*/
@Composable
fun Label(
text: String,
hasText: Boolean,
interactionSource: InteractionSource
) {
val isFocused by interactionSource.collectIsFocusedAsState()
Text(
text = text,
fontWeight = if (isFocused || hasText) FontWeight.Medium else FontWeight.Normal
)
}
private fun String.createSelection(): TextRange {
return when {
isEmpty() -> TextRange.Zero
@@ -6,6 +6,7 @@
package org.signal.appsettings.totpnameentry
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -36,6 +37,7 @@ import org.signal.core.ui.compose.DayNightPreviews
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
@VisibleForTesting
object TotpNameEntryTestTags {
@@ -53,6 +55,7 @@ fun TotpNameEntryScreen(
onEvent: (TotpNameEntryEvent) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@@ -86,7 +89,8 @@ fun TotpNameEntryScreen(
TextField(
value = state.name,
onValueChange = { onEvent(TotpNameEntryEvent.NameChanged(it)) },
label = { Text(text = stringResource(R.string.TotpNameEntryScreen__name)) },
label = { TextFields.Label(stringResource(R.string.TotpNameEntryScreen__name), state.name.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
singleLine = true,
enabled = !state.submitting,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
@@ -6,6 +6,7 @@
package org.signal.registration.screens.addusername
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -65,6 +66,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TextFields
import org.signal.core.util.UsernameUtil
import org.signal.libsignal.usernames.Username
import org.signal.registration.R
@@ -221,6 +223,7 @@ private fun ColumnScope.UsernameEntry(
onEvent: (AddUsernameScreenEvents) -> Unit
) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
val validationMessage: String? = state.validationError?.message()
LaunchedEffect(Unit) {
@@ -244,7 +247,8 @@ private fun ColumnScope.UsernameEntry(
TextField(
value = state.username,
onValueChange = { onEvent(AddUsernameScreenEvents.UsernameChanged(it)) },
label = { Text(stringResource(R.string.AddUsernameScreen__username)) },
label = { TextFields.Label(stringResource(R.string.AddUsernameScreen__username), state.username.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
singleLine = true,
enabled = !state.showSpinner,
isError = state.validationError != null,
@@ -5,6 +5,7 @@
package org.signal.registration.screens.aepentry
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -58,6 +59,7 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TextFields
import org.signal.passwordmanager.SignalCredentialManager
import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper
import org.signal.passwordmanager.compose.passwordAutoFillHelper
@@ -275,6 +277,7 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents)
var requestFocus by remember { mutableStateOf(true) }
val keyboardController = LocalSoftwareKeyboardController.current
val autoFillHelper = passwordAutoFillHelper { onEvent(EnterAepEvents.BackupKeyChanged(it)) }
val interactionSource = remember { MutableInteractionSource() }
TextField(
value = state.recoveryKey.enteredText,
@@ -282,7 +285,8 @@ private fun RecoveryKeyTextField(state: EnterAepState, onEvent: (EnterAepEvents)
onEvent(EnterAepEvents.BackupKeyChanged(it))
autoFillHelper.onValueChanged(it)
},
label = { Text(stringResource(R.string.EnterAepScreen__recovery_key)) },
label = { TextFields.Label(stringResource(R.string.EnterAepScreen__recovery_key), state.recoveryKey.enteredText.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
@@ -11,6 +11,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -50,6 +51,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScaffold
@@ -113,6 +115,9 @@ private fun CompactLayout(
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier
) {
val givenNameInteractionSource = remember { MutableInteractionSource() }
val familyNameInteractionSource = remember { MutableInteractionSource() }
RegistrationScaffold(
modifier = modifier
.fillMaxSize()
@@ -152,7 +157,8 @@ private fun CompactLayout(
OutlinedTextField(
value = state.givenName,
onValueChange = { onEvent(CreateProfileScreenEvents.GivenNameChanged(it)) },
label = { Text(stringResource(R.string.CreateProfileScreen__first_name_required)) },
label = { TextFields.Label(stringResource(R.string.CreateProfileScreen__first_name_required), state.givenName.isNotEmpty(), givenNameInteractionSource) },
interactionSource = givenNameInteractionSource,
singleLine = true,
enabled = !state.isSubmitting,
keyboardOptions = KeyboardOptions(
@@ -169,7 +175,8 @@ private fun CompactLayout(
OutlinedTextField(
value = state.familyName,
onValueChange = { onEvent(CreateProfileScreenEvents.FamilyNameChanged(it)) },
label = { Text(stringResource(R.string.CreateProfileScreen__last_name_optional)) },
label = { TextFields.Label(stringResource(R.string.CreateProfileScreen__last_name_optional), state.familyName.isNotEmpty(), familyNameInteractionSource) },
interactionSource = familyNameInteractionSource,
singleLine = true,
enabled = !state.isSubmitting,
keyboardOptions = KeyboardOptions(
@@ -5,6 +5,7 @@
package org.signal.registration.screens.localbackuprestore
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -51,6 +52,7 @@ import androidx.compose.ui.unit.sp
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TextFields
import org.signal.registration.R
import org.signal.registration.screens.OnePaneRegistrationScaffold
import org.signal.registration.screens.RegistrationScaffold
@@ -233,13 +235,15 @@ private fun PassphraseTextField(
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val keyboardController = LocalSoftwareKeyboardController.current
val interactionSource = remember { MutableInteractionSource() }
TextField(
value = passphrase,
onValueChange = { newValue ->
onPassphraseChange(newValue.filter { it.isDigit() })
},
label = { Text(stringResource(R.string.LocalBackupRestoreScreen__passphrase)) },
label = { TextFields.Label(stringResource(R.string.LocalBackupRestoreScreen__passphrase), passphrase.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
textStyle = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FontFamily.Monospace,
lineHeight = 36.sp
@@ -14,6 +14,7 @@ import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -80,6 +81,7 @@ import org.signal.core.ui.compose.IconButtons.IconButton
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.Util
import org.signal.core.util.logging.Log
import org.signal.registration.PendingRestoreOption
@@ -515,6 +517,7 @@ private fun PhoneNumberInputFields(
) {
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
val hasValidCountry = state.countryName.isNotEmpty()
val isAccountId = state.enteredAccountId != null
val label = if (isAccountId) R.string.RegistrationActivity_account_id else R.string.RegistrationActivity_phone_number_description
@@ -594,7 +597,8 @@ private fun PhoneNumberInputFields(
.weight(1f)
.focusRequester(focusRequester)
.testTag(TestTags.PHONE_NUMBER_PHONE_FIELD),
label = { Text(stringResource(label)) },
label = { TextFields.Label(stringResource(label), phoneNumberTextFieldValue.text.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
isError = state.isNumberInvalid || state.accountIdError != null,
supportingText = supportingText,
keyboardOptions = if (isAccountId) {
@@ -6,6 +6,7 @@
package org.signal.registration.screens.signallogincredentials
import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -65,6 +66,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.TextFields
import org.signal.passwordmanager.SignalCredentialManager
import org.signal.passwordmanager.compose.attachPasswordAutoFillHelper
import org.signal.passwordmanager.compose.passwordAutoFillHelper
@@ -302,10 +304,13 @@ private fun AccountIdTextField(
onEvent: (SignalLoginCredentialEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
TextField(
value = state.accountId,
onValueChange = { onEvent(SignalLoginCredentialEntryScreenEvents.AccountIdChanged(it)) },
label = { Text(stringResource(R.string.SignalLoginCredentialEntryScreen__account_id)) },
label = { TextFields.Label(stringResource(R.string.SignalLoginCredentialEntryScreen__account_id), state.accountId.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
singleLine = true,
textStyle = accountIdTextStyle(),
colors = TextFieldDefaults.colors(
@@ -344,6 +349,7 @@ private fun RecoveryKeyTextField(
if (revealed) AepVisualTransformation(RecoveryKeyGroups.GROUP_SIZE) else PasswordVisualTransformation()
}
val isError = state.recoveryKey.error != null || state.areCredentialsIncorrect
val interactionSource = remember { MutableInteractionSource() }
val minimizedLabelHeight = MaterialTheme.typography.bodySmall.lineHeight.takeIf { it.isSp } ?: MINIMIZED_LABEL_LINE_HEIGHT
val firstLineCenterY = with(LocalDensity.current) {
TEXT_FIELD_TOP_PADDING + minimizedLabelHeight.toDp() + RECOVERY_KEY_LINE_HEIGHT.toDp() / 2
@@ -356,7 +362,8 @@ private fun RecoveryKeyTextField(
onEvent(SignalLoginCredentialEntryScreenEvents.RecoveryKeyChanged(it))
autoFillHelper.onValueChanged(it)
},
label = { Text(stringResource(R.string.SignalLoginCredentialEntryScreen__recovery_key)) },
label = { TextFields.Label(stringResource(R.string.SignalLoginCredentialEntryScreen__recovery_key), state.recoveryKey.enteredText.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
singleLine = !revealed,
minLines = if (revealed) 3 else 1,
textStyle = MaterialTheme.typography.bodyLarge.copy(
@@ -8,6 +8,7 @@ package org.signal.registration.screens.signalloginpayment
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -34,6 +35,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -60,6 +62,7 @@ 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.SignalIcons
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.R
import org.signal.registration.screens.OnePaneRegistrationScaffold
@@ -451,12 +454,15 @@ private fun ManualReceiptCredentialEntry(
state: SignalLoginPaymentState,
onEvent: (SignalLoginPaymentScreenEvents) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
Spacer(modifier = Modifier.height(16.dp))
TextField(
value = state.manualReceiptCredential.value,
onValueChange = { onEvent(SignalLoginPaymentScreenEvents.ManualReceiptCredentialChanged(ManualReceiptCredential(it))) },
label = { Text(stringResource(R.string.SignalLoginPaymentScreen__paste_a_receipt_credential)) },
label = { TextFields.Label(stringResource(R.string.SignalLoginPaymentScreen__paste_a_receipt_credential), state.manualReceiptCredential.value.isNotEmpty(), interactionSource) },
interactionSource = interactionSource,
enabled = !state.showSpinner,
maxLines = 3,
modifier = Modifier