diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt index e2e1c6a..5a95758 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt @@ -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(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt index 140fe65..ab3b0b4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt @@ -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().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().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(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt index af69b6d..fadd5dd 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt @@ -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) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt index 1286db9..79d8691 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt @@ -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(), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt index 3d04d89..f62bd82 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt @@ -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) {