mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Auto-submit on Enter
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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,6 +76,58 @@ internal fun passwordStepPage(
|
||||
val unexpected = stringResource(Res.string.error_unexpected)
|
||||
val loginLabel = stringResource(Res.string.login)
|
||||
|
||||
fun attemptSubmit() {
|
||||
if (busy) return
|
||||
|
||||
if (password.length !in 5..50) {
|
||||
onSnackbar(pwdLen, null)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
when (
|
||||
val result = authPasswordStep(
|
||||
username = username,
|
||||
password = password,
|
||||
wrongPasswordMessage = wrongPassword,
|
||||
rateLimitMessage = rateLimit,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
is PasswordStepResult.LoginSuccess -> {
|
||||
onLoginSuccess()
|
||||
}
|
||||
|
||||
is PasswordStepResult.NeedsRegister -> {
|
||||
onNeedsRegister(
|
||||
result.yandexRequired,
|
||||
result.yandex,
|
||||
result.captchaRequired,
|
||||
result.captcha,
|
||||
)
|
||||
}
|
||||
|
||||
is PasswordStepResult.WrongPassword -> {
|
||||
onSnackbar(result.message, null)
|
||||
}
|
||||
|
||||
is PasswordStepResult.RateLimited -> {
|
||||
onSnackbar(result.message, null)
|
||||
}
|
||||
|
||||
is PasswordStepResult.Error -> {
|
||||
onSnackbar(result.message, result.cause)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Lock,
|
||||
@@ -94,9 +149,12 @@ internal fun passwordStepPage(
|
||||
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 }) {
|
||||
@@ -115,57 +173,7 @@ internal fun passwordStepPage(
|
||||
listFooter = { ChangeServerButton() },
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
|
||||
if (password.length !in 5..50) {
|
||||
onSnackbar(pwdLen, null)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
when (
|
||||
val result = authPasswordStep(
|
||||
username = username,
|
||||
password = password,
|
||||
wrongPasswordMessage = wrongPassword,
|
||||
rateLimitMessage = rateLimit,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
is PasswordStepResult.LoginSuccess -> {
|
||||
onLoginSuccess()
|
||||
}
|
||||
|
||||
is PasswordStepResult.NeedsRegister -> {
|
||||
onNeedsRegister(
|
||||
result.yandexRequired,
|
||||
result.yandex,
|
||||
result.captchaRequired,
|
||||
result.captcha,
|
||||
)
|
||||
}
|
||||
|
||||
is PasswordStepResult.WrongPassword -> {
|
||||
onSnackbar(result.message, null)
|
||||
}
|
||||
|
||||
is PasswordStepResult.RateLimited -> {
|
||||
onSnackbar(result.message, null)
|
||||
}
|
||||
|
||||
is PasswordStepResult.Error -> {
|
||||
onSnackbar(result.message, result.cause)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
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,6 +73,46 @@ internal fun usernameStepPage(
|
||||
val nextLabel = stringResource(Res.string.settings_next)
|
||||
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
|
||||
|
||||
fun attemptSubmit() {
|
||||
if (busy || hasProhibitedChars) return
|
||||
val trimmed = username.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
onSnackbar(fillAll, null)
|
||||
return
|
||||
}
|
||||
if (trimmed.length !in 3..20) {
|
||||
onSnackbar(usernameLenError, null)
|
||||
return
|
||||
}
|
||||
onUsernameChange(trimmed)
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
if (!probeCurrentServer()) {
|
||||
onSnackbar(serverFail, null)
|
||||
} else {
|
||||
try {
|
||||
ApiClient.authUsernameStep(trimmed)
|
||||
onContinue()
|
||||
} catch (e: ClientRequestException) {
|
||||
val detail = if (e.response.status.value == 400) {
|
||||
runCatching { e.response.body<ErrorResponse>().detail }
|
||||
.getOrNull()
|
||||
?.ifBlank { null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onSnackbar(detail ?: unexpected, e)
|
||||
} catch (e: Exception) {
|
||||
onSnackbar(unexpected, e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Person,
|
||||
@@ -95,8 +138,11 @@ internal fun usernameStepPage(
|
||||
.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) }
|
||||
@@ -110,45 +156,7 @@ internal fun usernameStepPage(
|
||||
listFooter = { ChangeServerButton() },
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy || hasProhibitedChars) return@ActionButton
|
||||
val trimmed = username.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
onSnackbar(fillAll, null)
|
||||
return@ActionButton
|
||||
}
|
||||
if (trimmed.length !in 3..20) {
|
||||
onSnackbar(usernameLenError, null)
|
||||
return@ActionButton
|
||||
}
|
||||
onUsernameChange(trimmed)
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
if (!probeCurrentServer()) {
|
||||
onSnackbar(serverFail, null)
|
||||
} else {
|
||||
try {
|
||||
ApiClient.authUsernameStep(trimmed)
|
||||
onContinue()
|
||||
} catch (e: ClientRequestException) {
|
||||
val detail = if (e.response.status.value == 400) {
|
||||
runCatching { e.response.body<ErrorResponse>().detail }
|
||||
.getOrNull()
|
||||
?.ifBlank { null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onSnackbar(detail ?: unexpected, e)
|
||||
} catch (e: Exception) {
|
||||
onSnackbar(unexpected, e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = ::attemptSubmit,
|
||||
enabled = !busy && !hasProhibitedChars,
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+21
-13
@@ -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,6 +75,53 @@ internal fun profileStepPage(
|
||||
val usernameTaken = stringResource(Res.string.auth_username_taken)
|
||||
val registerLabel = stringResource(Res.string.register_button)
|
||||
|
||||
fun attemptSubmit() {
|
||||
if (busy) return
|
||||
|
||||
if (displayName.isBlank() || displayName.trim().length > DISPLAY_NAME_MAX) {
|
||||
onSnackbar(displayNameError, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (bio.trim().length > BIO_MAX) {
|
||||
onSnackbar(unexpected, null)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
when (
|
||||
val result = register(
|
||||
username = username,
|
||||
displayName = displayName.trim(),
|
||||
password = password,
|
||||
bio = bio.trim(),
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
is RegisterResult.Success -> {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
|
||||
is RegisterResult.UsernameTaken -> {
|
||||
onSnackbar(usernameTaken, null)
|
||||
onUsernameTaken()
|
||||
}
|
||||
|
||||
is RegisterResult.Error -> {
|
||||
onSnackbar(result.message, result.cause)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Face,
|
||||
@@ -92,9 +142,12 @@ internal fun profileStepPage(
|
||||
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))
|
||||
},
|
||||
@@ -126,52 +179,7 @@ internal fun profileStepPage(
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
|
||||
if (displayName.isBlank() || displayName.trim().length > DISPLAY_NAME_MAX) {
|
||||
onSnackbar(displayNameError, null)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
if (bio.trim().length > BIO_MAX) {
|
||||
onSnackbar(unexpected, null)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
when (
|
||||
val result = register(
|
||||
username = username,
|
||||
displayName = displayName.trim(),
|
||||
password = password,
|
||||
bio = bio.trim(),
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
is RegisterResult.Success -> {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
|
||||
is RegisterResult.UsernameTaken -> {
|
||||
onSnackbar(usernameTaken, null)
|
||||
onUsernameTaken()
|
||||
}
|
||||
|
||||
is RegisterResult.Error -> {
|
||||
onSnackbar(result.message, result.cause)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user