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.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors 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.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@@ -73,54 +76,12 @@ internal fun passwordStepPage(
val unexpected = stringResource(Res.string.error_unexpected) val unexpected = stringResource(Res.string.error_unexpected)
val loginLabel = stringResource(Res.string.login) val loginLabel = stringResource(Res.string.login)
return ExpressiveStepPage( fun attemptSubmit() {
hero = ExpressiveHeroSpec( if (busy) return
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
if (password.length !in 5..50) { if (password.length !in 5..50) {
onSnackbar(pwdLen, null) onSnackbar(pwdLen, null)
return@ActionButton return
} }
scope.launch { scope.launch {
@@ -165,7 +126,54 @@ internal fun passwordStepPage(
busy = false 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, enabled = !busy,
loading = busy, loading = busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -39,6 +39,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors 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.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username import ru.fromchat.username
@@ -70,56 +73,16 @@ internal fun usernameStepPage(
val nextLabel = stringResource(Res.string.settings_next) val nextLabel = stringResource(Res.string.settings_next)
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) } val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
return ExpressiveStepPage( fun attemptSubmit() {
hero = ExpressiveHeroSpec( if (busy || hasProhibitedChars) return
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
val trimmed = username.trim() val trimmed = username.trim()
if (trimmed.isBlank()) { if (trimmed.isBlank()) {
onSnackbar(fillAll, null) onSnackbar(fillAll, null)
return@ActionButton return
} }
if (trimmed.length !in 3..20) { if (trimmed.length !in 3..20) {
onSnackbar(usernameLenError, null) onSnackbar(usernameLenError, null)
return@ActionButton return
} }
onUsernameChange(trimmed) onUsernameChange(trimmed)
scope.launch { scope.launch {
@@ -148,7 +111,52 @@ internal fun usernameStepPage(
busy = false 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, enabled = !busy && !hasProhibitedChars,
loading = busy, loading = busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -40,6 +40,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors 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.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@@ -59,6 +62,20 @@ internal fun confirmPasswordStepPage(
val pwdMatch = stringResource(Res.string.passwords_dont_match) val pwdMatch = stringResource(Res.string.passwords_dont_match)
val nextLabel = stringResource(Res.string.settings_next) 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( return ExpressiveStepPage(
hero = ExpressiveHeroSpec( hero = ExpressiveHeroSpec(
icon = Icons.Filled.VerifiedUser, icon = Icons.Filled.VerifiedUser,
@@ -81,8 +98,11 @@ internal fun confirmPasswordStepPage(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY) .trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.expressiveStepSubmitOnEnter(::attemptSubmit)
.padding(horizontal = SettingsStepHorizontalPadding), .padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true, singleLine = true,
keyboardOptions = expressiveStepSingleLineKeyboardOptions(),
keyboardActions = expressiveStepSubmitKeyboardActions(::attemptSubmit),
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = { trailingIcon = {
IconButton(onClick = { visible = !visible }) { IconButton(onClick = { visible = !visible }) {
@@ -100,19 +120,7 @@ internal fun confirmPasswordStepPage(
}, },
button = { button = {
ActionButton( ActionButton(
onClick = { onClick = ::attemptSubmit,
if (confirmPassword.isBlank()) {
onSnackbar(fillAll, null)
return@ActionButton
}
if (confirmPassword != password) {
onSnackbar(pwdMatch, null)
return@ActionButton
}
scope.launch { onContinue() }
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(nextLabel) Text(nextLabel)
@@ -40,6 +40,9 @@ import ru.fromchat.ui.components.ExpressiveStepPageHeader
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors 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.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
@@ -72,71 +75,17 @@ internal fun profileStepPage(
val usernameTaken = stringResource(Res.string.auth_username_taken) val usernameTaken = stringResource(Res.string.auth_username_taken)
val registerLabel = stringResource(Res.string.register_button) val registerLabel = stringResource(Res.string.register_button)
return ExpressiveStepPage( fun attemptSubmit() {
hero = ExpressiveHeroSpec( if (busy) return
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
if (displayName.isBlank() || displayName.trim().length > DISPLAY_NAME_MAX) { if (displayName.isBlank() || displayName.trim().length > DISPLAY_NAME_MAX) {
onSnackbar(displayNameError, null) onSnackbar(displayNameError, null)
return@ActionButton return
} }
if (bio.trim().length > BIO_MAX) { if (bio.trim().length > BIO_MAX) {
onSnackbar(unexpected, null) onSnackbar(unexpected, null)
return@ActionButton return
} }
scope.launch { scope.launch {
@@ -171,7 +120,66 @@ internal fun profileStepPage(
busy = false 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, enabled = !busy,
loading = busy, loading = busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -2,6 +2,8 @@ package ru.fromchat.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.BringIntoViewSpec 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.LocalBringIntoViewSpec
import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
@@ -20,6 +22,13 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusRequester 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.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged 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. */ /** When true, the current step's primary field should request focus once. */
val LocalExpressiveStepAutoFocusPrimary = compositionLocalOf { false } 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. */ /** Requests focus for [focusRequester] when [LocalExpressiveStepAutoFocusPrimary] becomes true. */
@Composable @Composable
fun ExpressiveStepAutoFocusEffect(focusRequester: FocusRequester) { fun ExpressiveStepAutoFocusEffect(focusRequester: FocusRequester) {