Auto-submit on Enter

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-30 21:18:16 +03:00
Unverified
parent 4f9011e656
commit 3f17419539
5 changed files with 205 additions and 149 deletions
@@ -45,6 +45,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.expressiveStepSingleLineKeyboardOptions
import ru.fromchat.ui.components.expressiveStepSubmitKeyboardActions
import ru.fromchat.ui.components.expressiveStepSubmitOnEnter
import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@@ -73,54 +76,12 @@ internal fun passwordStepPage(
val unexpected = stringResource(Res.string.error_unexpected)
val loginLabel = stringResource(Res.string.login)
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.Lock,
polygon = MaterialShapes.Cookie7Sided.normalized(),
containerColor = colorScheme.secondaryContainer,
contentColor = colorScheme.onSecondaryContainer,
),
content = { imeScroll ->
var visible by rememberSaveable { mutableStateOf(false) }
ExpressiveStepPageHeader(
title = stringResource(Res.string.auth_step_password_title),
body = stringResource(Res.string.auth_step_password_body),
)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(stringResource(Res.string.password)) },
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = !busy,
singleLine = true,
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(
imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = stringResource(
if (visible) Res.string.hide_password else Res.string.show_password,
),
)
}
},
colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape,
)
},
listFooter = { ChangeServerButton() },
button = {
ActionButton(
onClick = {
if (busy) return@ActionButton
fun attemptSubmit() {
if (busy) return
if (password.length !in 5..50) {
onSnackbar(pwdLen, null)
return@ActionButton
return
}
scope.launch {
@@ -165,7 +126,54 @@ internal fun passwordStepPage(
busy = false
}
}
}
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.Lock,
polygon = MaterialShapes.Cookie7Sided.normalized(),
containerColor = colorScheme.secondaryContainer,
contentColor = colorScheme.onSecondaryContainer,
),
content = { imeScroll ->
var visible by rememberSaveable { mutableStateOf(false) }
ExpressiveStepPageHeader(
title = stringResource(Res.string.auth_step_password_title),
body = stringResource(Res.string.auth_step_password_body),
)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(stringResource(Res.string.password)) },
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.expressiveStepSubmitOnEnter(::attemptSubmit)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = !busy,
singleLine = true,
keyboardOptions = expressiveStepSingleLineKeyboardOptions(),
keyboardActions = expressiveStepSubmitKeyboardActions(::attemptSubmit),
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(
imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = stringResource(
if (visible) Res.string.hide_password else Res.string.show_password,
),
)
}
},
colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape,
)
},
listFooter = { ChangeServerButton() },
button = {
ActionButton(
onClick = ::attemptSubmit,
enabled = !busy,
loading = busy,
modifier = Modifier.fillMaxWidth(),
@@ -39,6 +39,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.expressiveStepSingleLineKeyboardOptions
import ru.fromchat.ui.components.expressiveStepSubmitKeyboardActions
import ru.fromchat.ui.components.expressiveStepSubmitOnEnter
import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username
@@ -70,56 +73,16 @@ internal fun usernameStepPage(
val nextLabel = stringResource(Res.string.settings_next)
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.Person,
polygon = MaterialShapes.Cookie4Sided.normalized(),
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer,
),
autoFocusPrimaryField = true,
content = { imeScroll ->
val focusRequester = remember { FocusRequester() }
ExpressiveStepAutoFocusEffect(focusRequester)
ExpressiveStepPageHeader(
title = stringResource(Res.string.auth_step_username_title),
body = stringResource(Res.string.auth_step_username_body),
)
OutlinedTextField(
value = username,
onValueChange = onUsernameChange,
label = { Text(stringResource(Res.string.username)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true,
isError = hasProhibitedChars,
supportingText = if (hasProhibitedChars) {
{ Text(usernameCharsError) }
} else {
null
},
colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape,
)
},
listFooter = { ChangeServerButton() },
button = {
ActionButton(
onClick = {
if (busy || hasProhibitedChars) return@ActionButton
fun attemptSubmit() {
if (busy || hasProhibitedChars) return
val trimmed = username.trim()
if (trimmed.isBlank()) {
onSnackbar(fillAll, null)
return@ActionButton
return
}
if (trimmed.length !in 3..20) {
onSnackbar(usernameLenError, null)
return@ActionButton
return
}
onUsernameChange(trimmed)
scope.launch {
@@ -148,7 +111,52 @@ internal fun usernameStepPage(
busy = false
}
}
}
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.Person,
polygon = MaterialShapes.Cookie4Sided.normalized(),
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer,
),
autoFocusPrimaryField = true,
content = { imeScroll ->
val focusRequester = remember { FocusRequester() }
ExpressiveStepAutoFocusEffect(focusRequester)
ExpressiveStepPageHeader(
title = stringResource(Res.string.auth_step_username_title),
body = stringResource(Res.string.auth_step_username_body),
)
OutlinedTextField(
value = username,
onValueChange = onUsernameChange,
label = { Text(stringResource(Res.string.username)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.expressiveStepSubmitOnEnter(::attemptSubmit)
.padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true,
keyboardOptions = expressiveStepSingleLineKeyboardOptions(),
keyboardActions = expressiveStepSubmitKeyboardActions(::attemptSubmit),
isError = hasProhibitedChars,
supportingText = if (hasProhibitedChars) {
{ Text(usernameCharsError) }
} else {
null
},
colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape,
)
},
listFooter = { ChangeServerButton() },
button = {
ActionButton(
onClick = ::attemptSubmit,
enabled = !busy && !hasProhibitedChars,
loading = busy,
modifier = Modifier.fillMaxWidth(),
@@ -40,6 +40,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.expressiveStepSingleLineKeyboardOptions
import ru.fromchat.ui.components.expressiveStepSubmitKeyboardActions
import ru.fromchat.ui.components.expressiveStepSubmitOnEnter
import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@@ -59,6 +62,20 @@ internal fun confirmPasswordStepPage(
val pwdMatch = stringResource(Res.string.passwords_dont_match)
val nextLabel = stringResource(Res.string.settings_next)
fun attemptSubmit() {
if (confirmPassword.isBlank()) {
onSnackbar(fillAll, null)
return
}
if (confirmPassword != password) {
onSnackbar(pwdMatch, null)
return
}
scope.launch { onContinue() }
}
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.VerifiedUser,
@@ -81,8 +98,11 @@ internal fun confirmPasswordStepPage(
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.expressiveStepSubmitOnEnter(::attemptSubmit)
.padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true,
keyboardOptions = expressiveStepSingleLineKeyboardOptions(),
keyboardActions = expressiveStepSubmitKeyboardActions(::attemptSubmit),
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
@@ -100,19 +120,7 @@ internal fun confirmPasswordStepPage(
},
button = {
ActionButton(
onClick = {
if (confirmPassword.isBlank()) {
onSnackbar(fillAll, null)
return@ActionButton
}
if (confirmPassword != password) {
onSnackbar(pwdMatch, null)
return@ActionButton
}
scope.launch { onContinue() }
},
onClick = ::attemptSubmit,
modifier = Modifier.fillMaxWidth(),
) {
Text(nextLabel)
@@ -40,6 +40,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.expressiveStepSingleLineKeyboardOptions
import ru.fromchat.ui.components.expressiveStepSubmitKeyboardActions
import ru.fromchat.ui.components.expressiveStepSubmitOnEnter
import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@@ -72,71 +75,17 @@ internal fun profileStepPage(
val usernameTaken = stringResource(Res.string.auth_username_taken)
val registerLabel = stringResource(Res.string.register_button)
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.Face,
polygon = MaterialShapes.Cookie6Sided.normalized(),
containerColor = colorScheme.surfaceContainerHighest,
contentColor = colorScheme.onSurface,
),
content = { imeScroll ->
ExpressiveStepPageHeader(
title = stringResource(Res.string.auth_step_profile_title),
body = stringResource(Res.string.auth_step_profile_body),
)
OutlinedTextField(
value = displayName,
onValueChange = onDisplayNameChange,
label = { Text(stringResource(Res.string.display_name)) },
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = !busy,
singleLine = true,
supportingText = {
Text(stringResource(Res.string.auth_char_count, displayName.length, DISPLAY_NAME_MAX))
},
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = bio,
onValueChange = onBioChange,
label = { Text(stringResource(Res.string.profile_headline_bio)) },
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = !busy,
minLines = 3,
maxLines = 6,
supportingText = {
if (bio.isNotEmpty()) {
Text(stringResource(Res.string.auth_char_count, bio.length, BIO_MAX))
}
},
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
},
button = {
ActionButton(
onClick = {
if (busy) return@ActionButton
fun attemptSubmit() {
if (busy) return
if (displayName.isBlank() || displayName.trim().length > DISPLAY_NAME_MAX) {
onSnackbar(displayNameError, null)
return@ActionButton
return
}
if (bio.trim().length > BIO_MAX) {
onSnackbar(unexpected, null)
return@ActionButton
return
}
scope.launch {
@@ -171,7 +120,66 @@ internal fun profileStepPage(
busy = false
}
}
}
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
icon = Icons.Filled.Face,
polygon = MaterialShapes.Cookie6Sided.normalized(),
containerColor = colorScheme.surfaceContainerHighest,
contentColor = colorScheme.onSurface,
),
content = { imeScroll ->
ExpressiveStepPageHeader(
title = stringResource(Res.string.auth_step_profile_title),
body = stringResource(Res.string.auth_step_profile_body),
)
OutlinedTextField(
value = displayName,
onValueChange = onDisplayNameChange,
label = { Text(stringResource(Res.string.display_name)) },
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.expressiveStepSubmitOnEnter(::attemptSubmit)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = !busy,
singleLine = true,
keyboardOptions = expressiveStepSingleLineKeyboardOptions(),
keyboardActions = expressiveStepSubmitKeyboardActions(::attemptSubmit),
supportingText = {
Text(stringResource(Res.string.auth_char_count, displayName.length, DISPLAY_NAME_MAX))
},
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = bio,
onValueChange = onBioChange,
label = { Text(stringResource(Res.string.profile_headline_bio)) },
modifier = Modifier
.fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = !busy,
minLines = 3,
maxLines = 6,
supportingText = {
if (bio.isNotEmpty()) {
Text(stringResource(Res.string.auth_char_count, bio.length, BIO_MAX))
}
},
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
},
button = {
ActionButton(
onClick = ::attemptSubmit,
enabled = !busy,
loading = busy,
modifier = Modifier.fillMaxWidth(),
@@ -2,6 +2,8 @@ package ru.fromchat.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
@@ -20,6 +22,13 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
@@ -48,6 +57,21 @@ val LocalExpressiveStepFocusEnabled = compositionLocalOf { true }
/** When true, the current step's primary field should request focus once. */
val LocalExpressiveStepAutoFocusPrimary = compositionLocalOf { false }
/** Desktop Enter / Android IME action for expressive step single-line fields. */
fun Modifier.expressiveStepSubmitOnEnter(onSubmit: () -> Unit): Modifier = onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
if (event.key != Key.Enter && event.key != Key.NumPadEnter) return@onPreviewKeyEvent false
if (event.isShiftPressed) return@onPreviewKeyEvent false
onSubmit()
true
}
fun expressiveStepSingleLineKeyboardOptions(): KeyboardOptions =
KeyboardOptions(imeAction = ImeAction.Done)
fun expressiveStepSubmitKeyboardActions(onSubmit: () -> Unit): KeyboardActions =
KeyboardActions(onDone = { onSubmit() })
/** Requests focus for [focusRequester] when [LocalExpressiveStepAutoFocusPrimary] becomes true. */
@Composable
fun ExpressiveStepAutoFocusEffect(focusRequester: FocusRequester) {