mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement new account deletion and auth flows
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Keep Kotlin code clean — inline single-use helpers, match project conventions
|
||||
globs: app/shared/**/*.kt,utils/shared/**/*.kt
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Kotlin clean code
|
||||
|
||||
- If a function, variable, or small wrapper is used **once**, inline it at the call site unless it clarifies a non-obvious boundary (network call, animation controller, crypto).
|
||||
- Prefer existing project components (`ActionButton`, `Category`, `ExpressiveStepFlow`, `apiRequest`) over new abstractions.
|
||||
- Match surrounding naming, imports, and composable structure; read adjacent files before adding helpers.
|
||||
- Extract only when shared by **2+** call sites or when the block exceeds ~40 lines of non-trivial logic (pager math, crypto, API wiring).
|
||||
- No hardcoded user-visible strings in shared UI — use Compose Multiplatform resources (`composeResources/values/strings.xml` + `values-ru`).
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
description: Do not use ripgrep (rg) in shell commands or scripts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Never use `rg`
|
||||
|
||||
Do **not** run `rg` / ripgrep in terminal commands. It is unreliable in this environment.
|
||||
|
||||
## Use instead
|
||||
|
||||
- **Cursor Grep tool** — preferred for searching the codebase
|
||||
- **`grep -r`** — if a shell search is truly needed
|
||||
- **Glob / SemanticSearch** — for finding files or concepts
|
||||
|
||||
```bash
|
||||
# ❌ BAD
|
||||
rg "probeCurrentServer" app/shared
|
||||
|
||||
# ✅ GOOD — use the Grep tool, or:
|
||||
grep -r "probeCurrentServer" app/shared
|
||||
```
|
||||
@@ -25,6 +25,24 @@
|
||||
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
||||
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
||||
<string name="passwords_dont_match">Пароли не совпадают</string>
|
||||
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
|
||||
<string name="auth_welcome_tagline">100% бесплатный мессенджер с открытым исходным кодом.</string>
|
||||
<string name="auth_get_started">Начать</string>
|
||||
<string name="auth_server_connect_failed">Не удалось подключиться к серверу</string>
|
||||
<string name="auth_wrong_password">Неверный пароль</string>
|
||||
<string name="auth_rate_limit">Слишком много попыток. Попробуйте позже.</string>
|
||||
<string name="show_password">Показать пароль</string>
|
||||
<string name="hide_password">Скрыть пароль</string>
|
||||
<string name="auth_char_count">%1$d/%2$d</string>
|
||||
<string name="auth_step_username_title">Введите имя пользователя</string>
|
||||
<string name="auth_step_username_body">Это ваш логин. С помощью него мы сможем отличить вас от других людей.</string>
|
||||
<string name="auth_step_profile_title">Создайте ваш профиль</string>
|
||||
<string name="auth_step_profile_body">Введите ваше отображаемое имя и, если хотите, несколько слов о себе.</string>
|
||||
<string name="auth_username_taken">Это имя пользователя уже занято. Выберите другое.</string>
|
||||
<string name="auth_step_password_title">Введите пароль</string>
|
||||
<string name="auth_step_password_body">Мы войдём в аккаунт или создадим новый.</string>
|
||||
<string name="auth_step_confirm_title">Подтвердите пароль</string>
|
||||
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
|
||||
<string name="chats">Чаты</string>
|
||||
<string name="contacts">Контакты</string>
|
||||
<string name="profile">Профиль</string>
|
||||
@@ -267,12 +285,29 @@
|
||||
<string name="settings_hub_about_sub">Версия, ссылки и другое</string>
|
||||
|
||||
<string name="settings_account_title">Аккаунт</string>
|
||||
<string name="settings_account_logout_confirm_title">Выйти?</string>
|
||||
<string name="settings_account_logout_confirm_body">Придётся войти снова.</string>
|
||||
<string name="settings_account_delete">Удалить аккаунт</string>
|
||||
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
|
||||
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
||||
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
||||
<string name="settings_account_deleted">Аккаунт удалён</string>
|
||||
|
||||
<string name="settings_delete_step_intro_title">Удалить аккаунт?</string>
|
||||
<string name="settings_delete_step_intro_body">Ваш аккаунт будет удалён навсегда. Если вы подтвердите действие, произойдёт следующее:</string>
|
||||
<string name="settings_delete_consequence_chat_history">Вы потеряете всю историю переписки.</string>
|
||||
<string name="settings_delete_consequence_username">Ваше имя пользователя станет доступным всем, и его сможет занять любой.</string>
|
||||
<string name="settings_delete_consequence_messages">Отправленные вами сообщения будут анонимизированы.</string>
|
||||
<string name="settings_delete_consequence_permanent">Это действие нельзя отменить.</string>
|
||||
<string name="settings_delete_step_password_title">Подтвердите пароль</string>
|
||||
<string name="settings_delete_step_password_body">Введите пароль, чтобы продолжить.</string>
|
||||
<string name="settings_delete_step_final_title">Последнее предупреждение</string>
|
||||
<string name="settings_delete_step_final_body">Ваш аккаунт будет удалён НАВСЕГДА и его НЕВОЗМОЖНО восстановить. Вы выйдете на всех устройствах и сразу потеряете доступ.</string>
|
||||
<string name="settings_delete_account_button">Удалить аккаунт</string>
|
||||
<string name="settings_delete_confirm_phrase">я подтверждаю, что хочу удалить свой аккаунт</string>
|
||||
<string name="settings_delete_confirm_phrase_quote">Я подтверждаю, что хочу удалить свой аккаунт</string>
|
||||
<string name="settings_delete_confirm_phrase_instruction">Чтобы вы случайно не нажали кнопку, введите «%1$s» (без учёта регистра).</string>
|
||||
|
||||
<string name="error_unexpected">Что-то пошло не так</string>
|
||||
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
|
||||
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
|
||||
|
||||
@@ -32,7 +32,24 @@
|
||||
<string name="username_length_error">Username must be 3 to 20 characters</string>
|
||||
<string name="password_length_error">Password must be 5 to 50 characters</string>
|
||||
<string name="passwords_dont_match">The two passwords don’t match</string>
|
||||
|
||||
<string name="auth_welcome_title">Welcome to FromChat</string>
|
||||
<string name="auth_welcome_tagline">The 100% free and open source messenger.</string>
|
||||
<string name="auth_get_started">Get started</string>
|
||||
<string name="auth_server_connect_failed">Failed to connect to the server</string>
|
||||
<string name="auth_wrong_password">Wrong password</string>
|
||||
<string name="auth_rate_limit">Too many attempts. Try again later.</string>
|
||||
<string name="show_password">Show password</string>
|
||||
<string name="hide_password">Hide password</string>
|
||||
<string name="auth_char_count">%1$d/%2$d</string>
|
||||
<string name="auth_step_username_title">Enter your username</string>
|
||||
<string name="auth_step_username_body">This is your login. It helps us tell you apart from everyone else.</string>
|
||||
<string name="auth_step_profile_title">Create your profile</string>
|
||||
<string name="auth_step_profile_body">Enter your display name and, if you like, a few words about yourself.</string>
|
||||
<string name="auth_username_taken">This username was just taken. Please choose another one.</string>
|
||||
<string name="auth_step_password_title">Enter your password</string>
|
||||
<string name="auth_step_password_body">We will sign you in or create a new account.</string>
|
||||
<string name="auth_step_confirm_title">Confirm your password</string>
|
||||
<string name="auth_step_confirm_body">Enter the same password again.</string>
|
||||
<!-- Main Screen -->
|
||||
<string name="chats">Chats</string>
|
||||
<string name="contacts">Contacts</string>
|
||||
@@ -294,12 +311,29 @@
|
||||
<string name="settings_hub_about_sub">Version, links, and more</string>
|
||||
|
||||
<string name="settings_account_title">Account</string>
|
||||
<string name="settings_account_logout_confirm_title">Log out?</string>
|
||||
<string name="settings_account_logout_confirm_body">You will need to sign in again.</string>
|
||||
<string name="settings_account_delete">Delete account</string>
|
||||
<string name="settings_account_delete_d">Permanently delete your account and data</string>
|
||||
<string name="settings_account_delete_confirm_title">Delete account?</string>
|
||||
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
||||
<string name="settings_account_deleted">Account deleted</string>
|
||||
|
||||
<string name="settings_delete_step_intro_title">Do you want to delete your account?</string>
|
||||
<string name="settings_delete_step_intro_body">Your account will be deleted forever. If you confirm the action, this will happen:</string>
|
||||
<string name="settings_delete_consequence_chat_history">You will lose all your chat history.</string>
|
||||
<string name="settings_delete_consequence_username">Your username will become available to everyone and anyone can claim it.</string>
|
||||
<string name="settings_delete_consequence_messages">Your sent messages will be anonymized.</string>
|
||||
<string name="settings_delete_consequence_permanent">This cannot be undone.</string>
|
||||
<string name="settings_delete_step_password_title">Confirm your password</string>
|
||||
<string name="settings_delete_step_password_body">Enter your password to continue.</string>
|
||||
<string name="settings_delete_step_final_title">Last warning</string>
|
||||
<string name="settings_delete_step_final_body">Your account will be deleted FOREVER and CANNOT be recovered. You will be signed out on all devices and lose access immediately.</string>
|
||||
<string name="settings_delete_account_button">Delete account</string>
|
||||
<string name="settings_delete_confirm_phrase">i confirm that i want to delete my account</string>
|
||||
<string name="settings_delete_confirm_phrase_quote">I confirm that I want to delete my account</string>
|
||||
<string name="settings_delete_confirm_phrase_instruction">To avoid you accidentally clicking the button, please type “%1$s” (case-insensitive).</string>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<string name="error_unexpected">Something went wrong</string>
|
||||
<string name="error_invalid_credentials">Wrong username or password</string>
|
||||
|
||||
@@ -29,6 +29,7 @@ import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.encodedPath
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -43,6 +44,10 @@ import ru.fromchat.api.crypto.IdentityKeyManager
|
||||
import ru.fromchat.api.crypto.transport.TransportCiphertext
|
||||
import ru.fromchat.api.crypto.transport.TransportCrypto
|
||||
import ru.fromchat.api.instance.InstanceIdGuard
|
||||
import ru.fromchat.api.instance.InstanceIdResolveResult
|
||||
import ru.fromchat.api.instance.resolveInstanceId
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.cache.readOutboundFileBytes
|
||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||
@@ -71,9 +76,12 @@ import ru.fromchat.api.schema.server.RegisteredUserCountResponse
|
||||
import ru.fromchat.api.schema.server.ServerInstanceIdResponse
|
||||
import ru.fromchat.api.schema.server.TransportKeyResponse
|
||||
import ru.fromchat.api.schema.user.ChangePasswordApiRequest
|
||||
import ru.fromchat.api.schema.user.DeleteAccountRequest
|
||||
import ru.fromchat.api.schema.user.VerifyPasswordRequest
|
||||
import ru.fromchat.api.schema.user.FcmTokenRequest
|
||||
import ru.fromchat.api.schema.user.User
|
||||
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
||||
@@ -115,6 +123,8 @@ object ApiClient {
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// --- Session & suspension ---
|
||||
|
||||
data class SuspensionState(
|
||||
val isSuspended: Boolean = false,
|
||||
val reason: String? = null,
|
||||
@@ -164,6 +174,8 @@ object ApiClient {
|
||||
)
|
||||
}
|
||||
|
||||
// --- HTTP clients ---
|
||||
|
||||
val http = createPlatformHttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
@@ -209,6 +221,9 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
if (response.status.value == 401) {
|
||||
val path = response.call.request.url.encodedPath
|
||||
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
|
||||
if (!isCredentialCheck) {
|
||||
token = null
|
||||
user = null
|
||||
clearSuspensionState()
|
||||
@@ -218,6 +233,7 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.status.value == 403) {
|
||||
handleForbiddenAsPotentialSuspension(response)
|
||||
}
|
||||
@@ -269,6 +285,8 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server probe ---
|
||||
|
||||
suspend fun fetchServerInstanceId(apiBaseUrl: String): String {
|
||||
val base = apiBaseUrl.trimEnd('/')
|
||||
return httpProbe.get("$base/instance_id").body<ServerInstanceIdResponse>().instanceId.trim()
|
||||
@@ -290,11 +308,30 @@ object ApiClient {
|
||||
}.getOrDefault(false)
|
||||
|
||||
suspend fun refreshServerInstanceFingerprint() {
|
||||
if (token.isNullOrEmpty()) return
|
||||
val config = Settings.serverConfig
|
||||
runCatching {
|
||||
val id = fetchServerInstanceId(ServerConfig.apiBaseUrl)
|
||||
if (id.isNotEmpty()) {
|
||||
when (
|
||||
val resolve = resolveInstanceId(
|
||||
config = config,
|
||||
apiBaseUrl = ServerConfig.apiBaseUrl,
|
||||
forceNetwork = true,
|
||||
)
|
||||
) {
|
||||
is InstanceIdResolveResult.Cached,
|
||||
is InstanceIdResolveResult.Fetched,
|
||||
is InstanceIdResolveResult.InstanceIdChanged,
|
||||
-> {
|
||||
val id = when (resolve) {
|
||||
is InstanceIdResolveResult.Cached -> resolve.instanceId
|
||||
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
||||
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
||||
}
|
||||
Settings.lastKnownServerInstanceId = id
|
||||
InstanceRegistryStore.registerInstanceEncountered(id)
|
||||
CacheContext.setActiveInstance(id, user?.id)
|
||||
scheduleOutboxProcessing(id)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,12 +372,10 @@ object ApiClient {
|
||||
@Volatile
|
||||
var user: User? = null
|
||||
|
||||
// Global auth error handler
|
||||
var onAuthError: (() -> Unit)? = null
|
||||
|
||||
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? {
|
||||
return response.headers["suspension_reason"]?.trim()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? =
|
||||
response.headers["suspension_reason"]?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun handleForbiddenAsPotentialSuspension(response: HttpResponse) {
|
||||
if (response.status.value != 403) return
|
||||
@@ -349,7 +384,8 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Load persisted token and user info
|
||||
// --- Auth ---
|
||||
|
||||
suspend fun loadPersistedData() {
|
||||
try {
|
||||
val savedToken = secureSettings.getString("auth_token", "")
|
||||
@@ -383,6 +419,15 @@ object ApiClient {
|
||||
}
|
||||
.body()
|
||||
|
||||
/** Public pre-auth lookup; uses probe client so an existing session token is not sent. */
|
||||
suspend fun checkUsername(username: String): CheckUsernameResponse =
|
||||
httpProbe
|
||||
.get("${ServerConfig.apiBaseUrl}/check_username") {
|
||||
contentType(ContentType.Application.Json)
|
||||
parameter("username", username.trim())
|
||||
}
|
||||
.body()
|
||||
|
||||
/**
|
||||
* Sets in-memory auth only so follow-up calls (e.g. crypto upload) use Bearer token.
|
||||
* Persist with [persistSessionToStorage] only after identity keys are fully synced.
|
||||
@@ -410,6 +455,8 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- User & profile ---
|
||||
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
http
|
||||
.get("${ServerConfig.apiBaseUrl}/get_messages") {
|
||||
@@ -1181,14 +1228,23 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyPasswordDerived(passwordDerived: String) {
|
||||
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(VerifyPasswordRequest(passwordDerived = passwordDerived))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-delete account. Tries `/account/delete` (web client); falls back to `/delete` (bare FastAPI route) on 404.
|
||||
*/
|
||||
suspend fun deleteAccount(): SimpleStatusResponse {
|
||||
suspend fun deleteAccount(passwordDerived: String): SimpleStatusResponse {
|
||||
val body = DeleteAccountRequest(passwordDerived = passwordDerived)
|
||||
try {
|
||||
return http
|
||||
.post("${ServerConfig.apiBaseUrl}/account/delete") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
.body()
|
||||
} catch (e: ClientRequestException) {
|
||||
@@ -1196,6 +1252,7 @@ object ApiClient {
|
||||
return http
|
||||
.post("${ServerConfig.apiBaseUrl}/delete") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
.body()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ suspend inline fun <Response> apiRequest(
|
||||
onSuccess(response)
|
||||
return Result.success(response)
|
||||
} catch (e: ClientRequestException) {
|
||||
val message = if (e.response.status.value in arrayOf(401, 403)) {
|
||||
val message = if (e.response.status.value in arrayOf(401, 403, 429)) {
|
||||
e.response.body<ErrorResponse>().detail
|
||||
} else {
|
||||
unexpectedError
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.fromchat.api.schema.user
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DeleteAccountRequest(
|
||||
val passwordDerived: String,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.fromchat.api.schema.user
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VerifyPasswordRequest(
|
||||
val passwordDerived: String,
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ru.fromchat.api.schema.user.auth
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CheckUsernameResponse(
|
||||
val exists: Boolean,
|
||||
)
|
||||
+2
-1
@@ -7,5 +7,6 @@ data class RegisterRequest(
|
||||
val username: String,
|
||||
val display_name: String,
|
||||
val password: String,
|
||||
val confirm_password: String
|
||||
val confirm_password: String,
|
||||
val bio: String? = null,
|
||||
)
|
||||
@@ -40,6 +40,7 @@ import coil3.ImageLoader
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.svg.SvgDecoder
|
||||
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
|
||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||
import com.pr0gramm3r101.utils.rememberSystemBarsController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
@@ -69,8 +70,7 @@ import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
import ru.fromchat.ui.auth.RegisterScreen
|
||||
import ru.fromchat.ui.auth.AuthScreen
|
||||
import ru.fromchat.ui.calls.CallOverlay
|
||||
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
||||
import ru.fromchat.ui.chat.panels.dm.DmNav
|
||||
@@ -84,7 +84,8 @@ import ru.fromchat.ui.main.settings.DevicesScreen
|
||||
import ru.fromchat.ui.main.settings.NotificationsScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.account.AccountScreen
|
||||
import ru.fromchat.ui.main.settings.account.SettingsSecurityPasswordFlowScreen
|
||||
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
||||
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
||||
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.utils.NetworkConnectivity
|
||||
@@ -205,7 +206,7 @@ fun App(
|
||||
hasToken && startAtDmConversationUserId != null -> "chat"
|
||||
hasToken && startAtPublicChat -> "chats/publicChat"
|
||||
hasToken && !startAtPublicChat -> "chat"
|
||||
else -> "login"
|
||||
else -> "welcome"
|
||||
}
|
||||
|
||||
runCatching {
|
||||
@@ -233,7 +234,7 @@ fun App(
|
||||
LaunchedEffect(sessionLogoutRequired) {
|
||||
if (!sessionLogoutRequired) return@LaunchedEffect
|
||||
logoutIfInstanceUnsupported()
|
||||
startDestination = "login"
|
||||
startDestination = "welcome"
|
||||
sessionLogoutRequired = false
|
||||
}
|
||||
|
||||
@@ -313,7 +314,7 @@ fun App(
|
||||
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
|
||||
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
|
||||
)
|
||||
if (startDestination == null || startDestination == "login") {
|
||||
if (startDestination == null || startDestination == "welcome") {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
@@ -392,25 +393,28 @@ fun App(
|
||||
ServerConfigScreen()
|
||||
}
|
||||
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
WebSocketManager.connect(forceRestart = true)
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
composable("welcome") {
|
||||
WelcomeScreen(
|
||||
onGetStarted = {
|
||||
navController.navigate("auth") {
|
||||
popUpTo("auth") { inclusive = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") }
|
||||
onAlreadyLoggedIn = {
|
||||
WebSocketManager.connect(forceRestart = true)
|
||||
navController.navigateAndWipeBackStack("chat")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
onRegistered = {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
}
|
||||
composable("auth") {
|
||||
AuthScreen(
|
||||
onAuthSuccess = {
|
||||
WebSocketManager.connect(forceRestart = true)
|
||||
navController.navigateAndWipeBackStack("chat")
|
||||
},
|
||||
onBackToWelcome = { navController.navigateUp() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -599,11 +603,20 @@ fun App(
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
|
||||
SettingsSecurityPasswordFlowScreen(
|
||||
ChangePasswordScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
onDonePopToHub = {
|
||||
navController.popBackStack()
|
||||
onDone = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.AccountDeleteFlow, rootNavMotion) {
|
||||
DeleteAccountScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
onDeleted = {
|
||||
navController.navigate("welcome") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -611,11 +624,12 @@ fun App(
|
||||
AccountScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
onLogout = {
|
||||
navController.navigate("login") {
|
||||
navController.navigate("welcome") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
},
|
||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
|
||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
|
||||
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -624,9 +638,7 @@ fun App(
|
||||
ApiClient.onAuthError = {
|
||||
Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||
runCatching {
|
||||
navController.navigate("login") {
|
||||
popUpTo("chat") { inclusive = true }
|
||||
}
|
||||
navController.navigateAndWipeBackStack("welcome")
|
||||
}.onFailure { e ->
|
||||
Logger.w("App", "Auth navigation failed: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.auth_get_started
|
||||
import ru.fromchat.auth_welcome_tagline
|
||||
import ru.fromchat.auth_welcome_title
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onGetStarted: () -> Unit,
|
||||
onAlreadyLoggedIn: () -> Unit = {},
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (!ApiClient.token.isNullOrBlank()) {
|
||||
onAlreadyLoggedIn()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Res.getUri("drawable/logo_square.svg"),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_tagline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
ActionButton(onClick = onGetStarted) {
|
||||
Text(stringResource(Res.string.auth_get_started))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||
import ru.fromchat.api.instance.ServerProbeResult
|
||||
import ru.fromchat.api.instance.probeServer
|
||||
import ru.fromchat.api.schema.core.ErrorResponse
|
||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||
import ru.fromchat.ui.auth.register.profileStepPage
|
||||
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.TextCta
|
||||
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
private enum class AuthFlowStep {
|
||||
Username,
|
||||
Password,
|
||||
ConfirmPassword,
|
||||
Profile,
|
||||
}
|
||||
|
||||
internal sealed interface PasswordStepResult {
|
||||
data object LoginSuccess : PasswordStepResult
|
||||
data object AdvanceToRegister : PasswordStepResult
|
||||
data class WrongPassword(val message: String) : PasswordStepResult
|
||||
data class RateLimited(val message: String) : PasswordStepResult
|
||||
data class Error(val message: String) : PasswordStepResult
|
||||
}
|
||||
|
||||
internal sealed interface RegisterResult {
|
||||
data object Success : RegisterResult
|
||||
data object UsernameTaken : RegisterResult
|
||||
data class Error(val message: String) : RegisterResult
|
||||
}
|
||||
|
||||
private const val AUTH_SERVER_PROBE_TIMEOUT_MS = 5_000L
|
||||
|
||||
internal suspend fun probeCurrentServer() = runCatching {
|
||||
withTimeout(AUTH_SERVER_PROBE_TIMEOUT_MS.milliseconds) {
|
||||
probeServer(Settings.readServerConfig()) is ServerProbeResult.Supported
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
internal suspend fun authBranch(
|
||||
username: String,
|
||||
password: String,
|
||||
wrongPasswordMessage: String,
|
||||
rateLimitMessage: String,
|
||||
unexpectedError: String,
|
||||
) = login(
|
||||
username.trim(),
|
||||
password,
|
||||
wrongPasswordMessage,
|
||||
rateLimitMessage,
|
||||
unexpectedError,
|
||||
).let { result ->
|
||||
if (
|
||||
result is PasswordStepResult.WrongPassword &&
|
||||
!runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(true)
|
||||
) {
|
||||
PasswordStepResult.AdvanceToRegister
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fullLogin(
|
||||
username: String,
|
||||
password: String,
|
||||
request: suspend () -> LoginResponse,
|
||||
) {
|
||||
ApiClient.clearMemorySession()
|
||||
|
||||
val response = request()
|
||||
|
||||
ApiClient.bindSession(response)
|
||||
|
||||
try {
|
||||
IdentityKeyManager.ensureKeysOnLogin(
|
||||
username = username,
|
||||
password = password,
|
||||
token = response.token,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiClient.clearMemorySession()
|
||||
throw e
|
||||
}
|
||||
|
||||
ApiClient.persistSessionToStorage(response)
|
||||
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
||||
}
|
||||
|
||||
private suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
wrongPasswordMessage: String,
|
||||
rateLimitMessage: String,
|
||||
unexpectedError: String,
|
||||
) = try {
|
||||
fullLogin(username, password.trim()) {
|
||||
ApiClient.loginRequest(
|
||||
LoginRequest(username, deriveAuthSecret(username, password.trim())),
|
||||
)
|
||||
}
|
||||
|
||||
PasswordStepResult.LoginSuccess
|
||||
} catch (e: ClientRequestException) {
|
||||
when (e.response.status.value) {
|
||||
401 -> PasswordStepResult.WrongPassword(
|
||||
parseClientError(e, wrongPasswordMessage).ifBlank { wrongPasswordMessage },
|
||||
)
|
||||
|
||||
429 -> PasswordStepResult.RateLimited(
|
||||
parseClientError(e, rateLimitMessage).ifBlank { rateLimitMessage },
|
||||
)
|
||||
|
||||
else -> PasswordStepResult.Error(parseClientError(e, unexpectedError))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
PasswordStepResult.Error(unexpectedError)
|
||||
}
|
||||
|
||||
internal suspend fun register(
|
||||
username: String,
|
||||
displayName: String,
|
||||
password: String,
|
||||
bio: String,
|
||||
unexpectedError: String,
|
||||
) = try {
|
||||
if (runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(false)) {
|
||||
RegisterResult.UsernameTaken
|
||||
} else {
|
||||
fullLogin(username.trim(), password.trim()) {
|
||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||
|
||||
ApiClient.registerRequest(
|
||||
RegisterRequest(
|
||||
username = username.trim(),
|
||||
display_name = displayName.trim(),
|
||||
password = derived,
|
||||
confirm_password = derived,
|
||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
RegisterResult.Success
|
||||
}
|
||||
} catch (e: ClientRequestException) {
|
||||
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
||||
RegisterResult.UsernameTaken
|
||||
} else {
|
||||
RegisterResult.Error(parseClientError(e, unexpectedError))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
RegisterResult.Error(unexpectedError)
|
||||
}
|
||||
|
||||
private suspend fun isUsernameTakenError(e: ClientRequestException) =
|
||||
runCatching { e.response.body<ErrorResponse>().detail }.getOrNull().orEmpty().let {
|
||||
it.contains("уже занято", ignoreCase = true) ||
|
||||
it.contains("already taken", ignoreCase = true)
|
||||
}
|
||||
|
||||
private suspend fun parseClientError(e: ClientRequestException, fallback: String): String {
|
||||
return if (e.response.status.value in arrayOf(401, 403, 429, 400)) {
|
||||
runCatching { e.response.body<ErrorResponse>().detail }.getOrDefault(fallback)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChangeServerButton(modifier: Modifier = Modifier) {
|
||||
val navController = LocalNavController.current
|
||||
|
||||
TextCta(
|
||||
onClick = { navController.navigate("serverConfig") },
|
||||
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding).then(modifier),
|
||||
leadingIcon = Icons.Filled.Storage,
|
||||
) {
|
||||
Text(stringResource(Res.string.change_server))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
onAuthSuccess: () -> Unit,
|
||||
onBackToWelcome: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val flowState = rememberExpressiveStepFlow(AuthFlowStep.entries.size)
|
||||
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var bio by remember { mutableStateOf("") }
|
||||
|
||||
fun snackbar(text: String) {
|
||||
scope.launch {
|
||||
snackbarHostState.showReplacingSnackbar(
|
||||
message = text,
|
||||
withDismissAction = false,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val resetToUsername: () -> Unit = {
|
||||
username = ""
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
displayName = ""
|
||||
bio = ""
|
||||
flowState.resetPredictiveState()
|
||||
scope.launch {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Username.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
username = ""
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
displayName = ""
|
||||
bio = ""
|
||||
}
|
||||
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
usernameStepPage(
|
||||
username = username,
|
||||
onUsernameChange = { username = it },
|
||||
onContinue = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
passwordStepPage(
|
||||
username = username,
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
onLoginSuccess = onAuthSuccess,
|
||||
onRegister = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
confirmPasswordStepPage(
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
profileStepPage(
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
onDisplayNameChange = { displayName = it },
|
||||
bio = bio,
|
||||
onBioChange = { bio = it },
|
||||
password = password,
|
||||
onRegisterSuccess = onAuthSuccess,
|
||||
onUsernameTaken = resetToUsername,
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
),
|
||||
snackbarHostState = snackbarHostState,
|
||||
onBackAtFirstPage = onBackToWelcome,
|
||||
)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.apiRequest
|
||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.login
|
||||
import ru.fromchat.login_d
|
||||
import ru.fromchat.more
|
||||
import ru.fromchat.password
|
||||
import ru.fromchat.register_button
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.RowHeader
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.welcome
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNavigateToRegister: () -> Unit
|
||||
) {
|
||||
val errorUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
actions = {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
expanded = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(Res.string.more)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false } // Закрыть при нажатии вне меню
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringResource(Res.string.change_server))
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
navController.navigate("serverConfig")
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Storage,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var alert by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
RowHeader(
|
||||
icon = Icons.AutoMirrored.Filled.Login,
|
||||
title = stringResource(Res.string.welcome),
|
||||
subtitle = stringResource(Res.string.login_d)
|
||||
)
|
||||
|
||||
if (alert != null) {
|
||||
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(Res.string.username)) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(Res.string.password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
alert = alertErrorFilling
|
||||
return@Button
|
||||
}
|
||||
|
||||
// Derive auth secret before sending (matches frontend implementation)
|
||||
scope.launch {
|
||||
val trimmedUsername = username.trim()
|
||||
val trimmedPassword = password.trim()
|
||||
val derived = deriveAuthSecret(trimmedUsername, trimmedPassword)
|
||||
|
||||
apiRequest(
|
||||
unexpectedError = errorUnexpected,
|
||||
onError = { message, _ ->
|
||||
alert = message
|
||||
},
|
||||
onSuccess = {
|
||||
onLoginSuccess()
|
||||
}
|
||||
) {
|
||||
val response = ApiClient.loginRequest(
|
||||
LoginRequest(
|
||||
trimmedUsername,
|
||||
derived
|
||||
)
|
||||
)
|
||||
ApiClient.bindSession(response)
|
||||
try {
|
||||
IdentityKeyManager.ensureKeysOnLogin(
|
||||
username = trimmedUsername,
|
||||
password = trimmedPassword,
|
||||
token = response.token
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiClient.clearMemorySession()
|
||||
throw e
|
||||
}
|
||||
ApiClient.persistSessionToStorage(response)
|
||||
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.login))
|
||||
}
|
||||
|
||||
Button(onClick = onNavigateToRegister) {
|
||||
Text(stringResource(Res.string.register_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.auth_rate_limit
|
||||
import ru.fromchat.auth_step_password_body
|
||||
import ru.fromchat.auth_step_password_title
|
||||
import ru.fromchat.auth_wrong_password
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.hide_password
|
||||
import ru.fromchat.login
|
||||
import ru.fromchat.password
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.show_password
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun passwordStepPage(
|
||||
username: String,
|
||||
password: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLoginSuccess: () -> Unit,
|
||||
onRegister: suspend () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val pwdLen = stringResource(Res.string.password_length_error)
|
||||
val wrongPassword = stringResource(Res.string.auth_wrong_password)
|
||||
val rateLimit = stringResource(Res.string.auth_rate_limit)
|
||||
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
|
||||
|
||||
if (password.length !in 5..50) {
|
||||
onSnackbar(pwdLen)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
when (
|
||||
val result = authBranch(
|
||||
username = username,
|
||||
password = password,
|
||||
wrongPasswordMessage = wrongPassword,
|
||||
rateLimitMessage = rateLimit,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
is PasswordStepResult.LoginSuccess -> {
|
||||
onLoginSuccess()
|
||||
}
|
||||
|
||||
is PasswordStepResult.AdvanceToRegister -> {
|
||||
onRegister()
|
||||
}
|
||||
|
||||
is PasswordStepResult.WrongPassword -> {
|
||||
onSnackbar(result.message)
|
||||
}
|
||||
|
||||
is PasswordStepResult.RateLimited -> {
|
||||
onSnackbar(result.message)
|
||||
}
|
||||
|
||||
is PasswordStepResult.Error -> {
|
||||
onSnackbar(result.message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(loginLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.apiRequest
|
||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.confirm_password
|
||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||
import ru.fromchat.display_name
|
||||
import ru.fromchat.display_name_error
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.password
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.passwords_dont_match
|
||||
import ru.fromchat.register
|
||||
import ru.fromchat.register_button
|
||||
import ru.fromchat.register_d
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.RowHeader
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.username_length_error
|
||||
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
onRegistered: () -> Unit
|
||||
) {
|
||||
val errorUnexpected = stringResource(Res.string.error_unexpected)
|
||||
|
||||
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
|
||||
var username by remember { mutableStateOf("") }
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var alert by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
RowHeader(
|
||||
icon = Icons.Filled.PersonAdd,
|
||||
title = stringResource(Res.string.register),
|
||||
subtitle = stringResource(Res.string.register_d)
|
||||
)
|
||||
|
||||
if (alert != null) {
|
||||
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(Res.string.username)) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
label = { Text(stringResource(Res.string.display_name)) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(Res.string.password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text(stringResource(Res.string.confirm_password)) },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
|
||||
val alertErrorPasswordLittle = stringResource(Res.string.password_length_error)
|
||||
val alertErrorNameLittle = stringResource(Res.string.username_length_error)
|
||||
val alertErrorPasswordConfrim = stringResource(Res.string.passwords_dont_match)
|
||||
val alertErrorDisplayName = stringResource(Res.string.display_name_error)
|
||||
|
||||
IconButton(
|
||||
onClick = { navController.navigateUp() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
// Checks
|
||||
if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
|
||||
alert = alertErrorFilling
|
||||
return@Button
|
||||
}
|
||||
if (password != confirmPassword) {
|
||||
alert = alertErrorPasswordConfrim
|
||||
return@Button
|
||||
}
|
||||
if (username.length !in 3..20) {
|
||||
alert = alertErrorNameLittle
|
||||
return@Button
|
||||
}
|
||||
if (displayName.isBlank() || displayName.length > 64) {
|
||||
alert = alertErrorDisplayName
|
||||
return@Button
|
||||
}
|
||||
if (password.length !in 5..50) {
|
||||
alert = alertErrorPasswordLittle
|
||||
return@Button
|
||||
}
|
||||
|
||||
// Derive auth secret before sending (matches frontend implementation)
|
||||
scope.launch {
|
||||
val u = username.trim()
|
||||
val display = displayName.trim()
|
||||
val derived = deriveAuthSecret(u, password)
|
||||
|
||||
apiRequest(
|
||||
unexpectedError = errorUnexpected,
|
||||
onError = { message, _ ->
|
||||
alert = message
|
||||
},
|
||||
onSuccess = {
|
||||
onRegistered()
|
||||
}
|
||||
) {
|
||||
val response = ApiClient.registerRequest(
|
||||
RegisterRequest(
|
||||
u,
|
||||
display,
|
||||
derived,
|
||||
derived
|
||||
)
|
||||
)
|
||||
ApiClient.bindSession(response)
|
||||
try {
|
||||
IdentityKeyManager.ensureKeysOnLogin(
|
||||
username = u,
|
||||
password = password,
|
||||
token = response.token
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ApiClient.clearMemorySession()
|
||||
throw e
|
||||
}
|
||||
ApiClient.persistSessionToStorage(response)
|
||||
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(Res.string.register_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.auth_server_connect_failed
|
||||
import ru.fromchat.auth_step_username_body
|
||||
import ru.fromchat.auth_step_username_title
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepAutoFocusEffect
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.username_length_error
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun usernameStepPage(
|
||||
username: String,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onContinue: suspend () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val usernameLenError = stringResource(Res.string.username_length_error)
|
||||
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
||||
val nextLabel = stringResource(Res.string.settings_next)
|
||||
|
||||
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,
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
},
|
||||
listFooter = { ChangeServerButton() },
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
val trimmed = username.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
onSnackbar(fillAll)
|
||||
return@ActionButton
|
||||
}
|
||||
if (trimmed.length !in 3..20) {
|
||||
onSnackbar(usernameLenError)
|
||||
return@ActionButton
|
||||
}
|
||||
onUsernameChange(trimmed)
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
if (!probeCurrentServer()) {
|
||||
onSnackbar(serverFail)
|
||||
} else {
|
||||
onContinue()
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(nextLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ru.fromchat.ui.auth.register
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.auth_step_confirm_body
|
||||
import ru.fromchat.auth_step_confirm_title
|
||||
import ru.fromchat.confirm_password
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.hide_password
|
||||
import ru.fromchat.passwords_dont_match
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.show_password
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun confirmPasswordStepPage(
|
||||
confirmPassword: String,
|
||||
onConfirmPasswordChange: (String) -> Unit,
|
||||
password: String,
|
||||
onContinue: suspend () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val pwdMatch = stringResource(Res.string.passwords_dont_match)
|
||||
val nextLabel = stringResource(Res.string.settings_next)
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.VerifiedUser,
|
||||
polygon = MaterialShapes.VerySunny.normalized(),
|
||||
containerColor = colorScheme.tertiaryContainer,
|
||||
contentColor = colorScheme.onTertiaryContainer,
|
||||
),
|
||||
content = { imeScroll ->
|
||||
var visible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.auth_step_confirm_title),
|
||||
body = stringResource(Res.string.auth_step_confirm_body),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = onConfirmPasswordChange,
|
||||
label = { Text(stringResource(Res.string.confirm_password)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
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,
|
||||
)
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (confirmPassword.isBlank()) {
|
||||
onSnackbar(fillAll)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
if (confirmPassword != password) {
|
||||
onSnackbar(pwdMatch)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch { onContinue() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(nextLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package ru.fromchat.ui.auth.register
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Face
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.auth_char_count
|
||||
import ru.fromchat.auth_step_profile_body
|
||||
import ru.fromchat.auth_step_profile_title
|
||||
import ru.fromchat.auth_username_taken
|
||||
import ru.fromchat.display_name
|
||||
import ru.fromchat.display_name_error
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.profile_headline_bio
|
||||
import ru.fromchat.register_button
|
||||
import ru.fromchat.ui.auth.RegisterResult
|
||||
import ru.fromchat.ui.auth.register
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
private const val DISPLAY_NAME_MAX = 64
|
||||
private const val BIO_MAX = 500
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun profileStepPage(
|
||||
username: String,
|
||||
displayName: String,
|
||||
onDisplayNameChange: (String) -> Unit,
|
||||
bio: String,
|
||||
onBioChange: (String) -> Unit,
|
||||
password: String,
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onUsernameTaken: () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val fieldColors = expressiveStepFieldColors()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val displayNameError = stringResource(Res.string.display_name_error)
|
||||
val unexpected = stringResource(Res.string.error_unexpected)
|
||||
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
|
||||
|
||||
if (displayName.isBlank() || displayName.trim().length > DISPLAY_NAME_MAX) {
|
||||
onSnackbar(displayNameError)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
if (bio.trim().length > BIO_MAX) {
|
||||
onSnackbar(unexpected)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
when (
|
||||
val result = register(
|
||||
username = username,
|
||||
displayName = displayName.trim(),
|
||||
password = password,
|
||||
bio = bio.trim(),
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
is RegisterResult.Success -> {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
|
||||
is RegisterResult.UsernameTaken -> {
|
||||
onSnackbar(usernameTaken)
|
||||
onUsernameTaken()
|
||||
}
|
||||
|
||||
is RegisterResult.Error -> {
|
||||
onSnackbar(result.message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(registerLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -33,7 +33,8 @@ fun PublicChatScreen(
|
||||
|
||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||
|
||||
LaunchedEffect(panel) {
|
||||
LaunchedEffect(panel, activeInstanceId) {
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
if (panel.getState().messages.isEmpty()) {
|
||||
panel.loadMessages()
|
||||
}
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
package ru.fromchat.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -34,27 +36,38 @@ fun ActionButton(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
outlined: Boolean = false,
|
||||
destructive: Boolean = false,
|
||||
leadingIcon: ImageVector? = null,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable (RowScope.() -> Unit)
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val showCtaAsPrimary = enabled || loading
|
||||
val ctaTargetContainer =
|
||||
if (showCtaAsPrimary)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val ctaTargetContent =
|
||||
if (showCtaAsPrimary)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
val ctaContainer by animateColorAsState(
|
||||
ctaTargetContainer,
|
||||
|
||||
val containerTarget = when {
|
||||
outlined -> scheme.surface
|
||||
showCtaAsPrimary && destructive -> scheme.error
|
||||
showCtaAsPrimary -> scheme.primary
|
||||
else -> scheme.surfaceContainerHigh
|
||||
}
|
||||
val contentTarget = when {
|
||||
outlined && showCtaAsPrimary -> scheme.primary
|
||||
outlined -> scheme.onSurface.copy(alpha = 0.38f)
|
||||
showCtaAsPrimary && destructive -> scheme.onError
|
||||
showCtaAsPrimary -> scheme.onPrimary
|
||||
else -> scheme.onSurface.copy(alpha = 0.38f)
|
||||
}
|
||||
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = containerTarget,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "serverConfigCtaContainer",
|
||||
label = "actionButtonContainer",
|
||||
)
|
||||
val ctaContent by animateColorAsState(
|
||||
ctaTargetContent,
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = contentTarget,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "serverConfigCtaContent",
|
||||
label = "actionButtonContent",
|
||||
)
|
||||
|
||||
Button(
|
||||
@@ -70,11 +83,12 @@ fun ActionButton(
|
||||
.then(modifier),
|
||||
shape = CtaShape,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = ctaContainer,
|
||||
contentColor = ctaContent,
|
||||
disabledContainerColor = ctaContainer,
|
||||
disabledContentColor = ctaContent,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = containerColor,
|
||||
disabledContentColor = contentColor,
|
||||
),
|
||||
border = if (outlined) BorderStroke(1.dp, scheme.outline) else null,
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
@@ -90,24 +104,27 @@ fun ActionButton(
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = loading,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith
|
||||
(fadeOut(tween(200)) + slideOutVertically { -it / 4 })
|
||||
},
|
||||
label = "cta",
|
||||
) { loading ->
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = ctaContent,
|
||||
color = contentColor,
|
||||
)
|
||||
} else {
|
||||
Row {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalTextAlign provides TextAlign.Center
|
||||
LocalTextAlign provides TextAlign.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -116,4 +133,54 @@ fun ActionButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextCta(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val contentColor =
|
||||
if (enabled) scheme.primary
|
||||
else scheme.onSurface.copy(alpha = 0.38f)
|
||||
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.then(modifier),
|
||||
shape = CtaShape,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
disabledContentColor = contentColor,
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalTextAlign provides TextAlign.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
package ru.fromchat.ui.components
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toPath
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.graphics.shapes.Morph
|
||||
import androidx.graphics.shapes.RoundedPolygon
|
||||
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import kotlin.math.abs
|
||||
|
||||
private val ExpressiveStepSnackbarAnchorGap = 8.dp
|
||||
|
||||
enum class ExpressiveStepSnackbarAnchorRole {
|
||||
/** Bottom-bar primary CTA (Next / Login / etc.). */
|
||||
PrimaryCta,
|
||||
/** In-list secondary action (e.g. change server). */
|
||||
SecondaryCta,
|
||||
}
|
||||
|
||||
@Stable
|
||||
class ExpressiveStepSnackbarAnchors {
|
||||
var primaryCtaTopInWindow by mutableStateOf<Float?>(null)
|
||||
internal set
|
||||
var secondaryCtaBoundsInWindow by mutableStateOf<Rect?>(null)
|
||||
internal set
|
||||
|
||||
internal fun anchorTopInWindow(listViewport: Rect?): Float? {
|
||||
val primaryTop = primaryCtaTopInWindow ?: return null
|
||||
val secondary = secondaryCtaBoundsInWindow
|
||||
if (secondary != null && secondary.height > 1f && listViewport != null) {
|
||||
val inView = secondary.bottom > listViewport.top && secondary.top < listViewport.bottom
|
||||
if (inView) return secondary.top
|
||||
}
|
||||
return primaryTop
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.trackExpressiveStepSnackbarAnchor(
|
||||
anchors: ExpressiveStepSnackbarAnchors,
|
||||
role: ExpressiveStepSnackbarAnchorRole,
|
||||
): Modifier = onGloballyPositioned { coordinates ->
|
||||
val bounds = coordinates.boundsInWindow()
|
||||
when (role) {
|
||||
ExpressiveStepSnackbarAnchorRole.PrimaryCta ->
|
||||
anchors.primaryCtaTopInWindow = bounds.top
|
||||
ExpressiveStepSnackbarAnchorRole.SecondaryCta ->
|
||||
anchors.secondaryCtaBoundsInWindow = bounds
|
||||
}
|
||||
}
|
||||
|
||||
/** Outline shape for expressive step-flow text fields. */
|
||||
val SettingsPasswordOutlineFieldShape = RoundedCornerShape(18.dp)
|
||||
|
||||
@Stable
|
||||
class ExpressiveStepFlowState internal constructor(
|
||||
val pagerState: PagerState,
|
||||
internal val scope: CoroutineScope,
|
||||
internal val pageCount: Int,
|
||||
) {
|
||||
internal var predictiveFromPage by mutableStateOf<Int?>(null)
|
||||
internal var predictiveToPage by mutableStateOf<Int?>(null)
|
||||
internal var predictiveProgress by mutableFloatStateOf(0f)
|
||||
|
||||
fun resetPredictiveState() {
|
||||
predictiveFromPage = null
|
||||
predictiveToPage = null
|
||||
predictiveProgress = 0f
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ExpressiveHeroSpec(
|
||||
val icon: ImageVector,
|
||||
val polygon: RoundedPolygon,
|
||||
val containerColor: Color,
|
||||
val contentColor: Color,
|
||||
)
|
||||
|
||||
/** One step in an expressive flow: scrollable [content] and its bottom-bar [button]. */
|
||||
@Stable
|
||||
class ExpressiveStepPage(
|
||||
val hero: ExpressiveHeroSpec,
|
||||
val content: @Composable (LazyListImeScrollState) -> Unit,
|
||||
val button: @Composable () -> Unit,
|
||||
val listFooter: (@Composable () -> Unit)? = null,
|
||||
val autoFocusPrimaryField: Boolean = false,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberExpressiveStepFlow(pageCount: Int): ExpressiveStepFlowState {
|
||||
val pagerState = rememberPagerState(initialPage = 0, pageCount = { pageCount })
|
||||
val scope = rememberCoroutineScope()
|
||||
return remember(pagerState, scope, pageCount) {
|
||||
ExpressiveStepFlowState(pagerState, scope, pageCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun expressiveStepFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
focusedLabelColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
disabledBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
)
|
||||
|
||||
private val ExpressiveStepHeroTitleSpacing = 16.dp
|
||||
|
||||
/** [HorizontalPager] pages only honor content width unless forced to the page slot width. */
|
||||
private fun Modifier.pagerPageFullWidth(): Modifier = layout { measurable, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minWidth = width, maxWidth = width),
|
||||
)
|
||||
layout(width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveStepPageHeader(
|
||||
title: String,
|
||||
body: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = MaterialTheme.typography.titleLarge.lineHeight,
|
||||
)
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyPredictivePagerSync(
|
||||
pagerState: PagerState,
|
||||
fromPage: Int,
|
||||
toPage: Int,
|
||||
progress: Float,
|
||||
) {
|
||||
val clamped = progress.coerceIn(0f, 1f)
|
||||
val (page, offset) = when {
|
||||
clamped <= 0f -> fromPage to 0f
|
||||
clamped >= 1f -> toPage to 0f
|
||||
clamped <= 0.5f -> fromPage to (-clamped).coerceIn(-0.5f, 0f)
|
||||
else -> toPage to (1f - clamped).coerceIn(0f, 0.5f)
|
||||
}
|
||||
pagerState.scrollToPage(page = page, pageOffsetFraction = offset)
|
||||
}
|
||||
|
||||
private suspend fun finishPredictiveMorph(
|
||||
flowState: ExpressiveStepFlowState,
|
||||
pagerState: PagerState,
|
||||
startProgress: Float,
|
||||
targetProgress: Float,
|
||||
) {
|
||||
val fromPage = flowState.predictiveFromPage ?: return
|
||||
val toPage = flowState.predictiveToPage ?: return
|
||||
Animatable(startProgress).animateTo(
|
||||
targetValue = targetProgress,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
) {
|
||||
flowState.predictiveProgress = value
|
||||
}
|
||||
val settledPage = if (targetProgress >= 0.5f) toPage else fromPage
|
||||
snapshotFlow {
|
||||
pagerState.currentPage to pagerState.currentPageOffsetFraction
|
||||
}.first { (page, offset) ->
|
||||
page == settledPage && abs(offset) < 0.01f
|
||||
}
|
||||
flowState.resetPredictiveState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MorphedExpressiveStepButton(
|
||||
pages: List<ExpressiveStepPage>,
|
||||
fromIndex: Int,
|
||||
toIndex: Int,
|
||||
morphProgress: Float,
|
||||
morphing: Boolean,
|
||||
settledPage: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lastIndex = (pages.size - 1).coerceAtLeast(0)
|
||||
val settled = settledPage.coerceIn(0, lastIndex)
|
||||
val from = fromIndex.coerceIn(0, lastIndex)
|
||||
val to = toIndex.coerceIn(0, lastIndex)
|
||||
|
||||
Box(modifier.fillMaxWidth()) {
|
||||
if (morphing && from != to) {
|
||||
val p = morphProgress.coerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(if (p < 0.5f) 1f else 0f)
|
||||
.graphicsLayer { alpha = 1f - p },
|
||||
) {
|
||||
pages[from].button()
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(if (p >= 0.5f) 1f else 0f)
|
||||
.graphicsLayer { alpha = p },
|
||||
) {
|
||||
pages[to].button()
|
||||
}
|
||||
} else {
|
||||
pages[settled].button()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalAnimationApi::class,
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun ExpressiveStepFlowScaffold(
|
||||
flowState: ExpressiveStepFlowState,
|
||||
pages: List<ExpressiveStepPage>,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onBackAtFirstPage: () -> Unit,
|
||||
trailingTopBarActions: @Composable (() -> Unit)? = null,
|
||||
hazeScaffold: Boolean = true,
|
||||
) {
|
||||
val pagerState = flowState.pagerState
|
||||
val scope = flowState.scope
|
||||
val pageCount = pages.size.coerceAtLeast(1)
|
||||
val heroSpecs = remember(pages) { pages.map { it.hero } }
|
||||
val isHazeLazyMode = hazeScaffold
|
||||
val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction }
|
||||
val predictiveThreshold = 0.15f
|
||||
|
||||
PredictiveBackHandler(
|
||||
enabled = pagerState.currentPage > 0,
|
||||
onProgress = { p ->
|
||||
val clamped = p.coerceIn(0f, 1f)
|
||||
if (clamped <= 0f) {
|
||||
flowState.resetPredictiveState()
|
||||
} else {
|
||||
if (flowState.predictiveFromPage == null || flowState.predictiveToPage == null) {
|
||||
val fromPage = pagerState.currentPage
|
||||
val toPage = (fromPage - 1).coerceAtLeast(0)
|
||||
flowState.predictiveFromPage = fromPage
|
||||
flowState.predictiveToPage = toPage
|
||||
}
|
||||
flowState.predictiveProgress = clamped
|
||||
}
|
||||
},
|
||||
onCommit = {
|
||||
val fromPageSnapshot = flowState.predictiveFromPage
|
||||
val toPageSnapshot = flowState.predictiveToPage
|
||||
val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f)
|
||||
if (lastProgress < predictiveThreshold || fromPageSnapshot == null || toPageSnapshot == null) {
|
||||
scope.launch {
|
||||
finishPredictiveMorph(
|
||||
flowState = flowState,
|
||||
pagerState = pagerState,
|
||||
startProgress = lastProgress,
|
||||
targetProgress = 0f,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
finishPredictiveMorph(
|
||||
flowState = flowState,
|
||||
pagerState = pagerState,
|
||||
startProgress = lastProgress,
|
||||
targetProgress = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
val fromPageSnapshot = flowState.predictiveFromPage
|
||||
val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f)
|
||||
if (fromPageSnapshot == null) {
|
||||
flowState.resetPredictiveState()
|
||||
return@PredictiveBackHandler
|
||||
}
|
||||
scope.launch {
|
||||
val commit = lastProgress >= predictiveThreshold
|
||||
finishPredictiveMorph(
|
||||
flowState = flowState,
|
||||
pagerState = pagerState,
|
||||
startProgress = lastProgress,
|
||||
targetProgress = if (commit) 1f else 0f,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(
|
||||
flowState.predictiveFromPage,
|
||||
flowState.predictiveToPage,
|
||||
flowState.predictiveProgress,
|
||||
) {
|
||||
val fromPage = flowState.predictiveFromPage ?: return@LaunchedEffect
|
||||
val toPage = flowState.predictiveToPage ?: return@LaunchedEffect
|
||||
applyPredictivePagerSync(
|
||||
pagerState = pagerState,
|
||||
fromPage = fromPage,
|
||||
toPage = toPage,
|
||||
progress = flowState.predictiveProgress,
|
||||
)
|
||||
}
|
||||
|
||||
val lastIndex = (pageCount - 1).coerceAtLeast(0)
|
||||
val fromIndex: Int
|
||||
val toIndex: Int
|
||||
val morphProgress: Float
|
||||
if (flowState.predictiveFromPage != null && flowState.predictiveToPage != null && flowState.predictiveProgress > 0f) {
|
||||
fromIndex = flowState.predictiveFromPage!!
|
||||
toIndex = flowState.predictiveToPage!!
|
||||
morphProgress = flowState.predictiveProgress
|
||||
} else if (pageOffset < 0f) {
|
||||
fromIndex = pagerState.currentPage
|
||||
toIndex = (fromIndex - 1).coerceAtLeast(0)
|
||||
morphProgress = -pageOffset
|
||||
} else if (pageOffset > 0f) {
|
||||
fromIndex = pagerState.currentPage
|
||||
toIndex = (fromIndex + 1).coerceAtMost(lastIndex)
|
||||
morphProgress = pageOffset
|
||||
} else {
|
||||
fromIndex = pagerState.currentPage
|
||||
toIndex = fromIndex
|
||||
morphProgress = 0f
|
||||
}
|
||||
val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f)
|
||||
val currentPage = pagerState.currentPage
|
||||
val morphing = fromIndex != toIndex
|
||||
val fromSpec = heroSpecs.getOrElse(fromIndex) { heroSpecs.first() }
|
||||
val toSpec = heroSpecs.getOrElse(toIndex) { heroSpecs.first() }
|
||||
val currentSpec = heroSpecs.getOrElse(currentPage) { heroSpecs.first() }
|
||||
|
||||
val isPageTransitionSettled by remember {
|
||||
derivedStateOf {
|
||||
!pagerState.isScrollInProgress &&
|
||||
abs(pagerState.currentPageOffsetFraction) < 0.01f &&
|
||||
flowState.predictiveProgress <= 0f &&
|
||||
flowState.predictiveFromPage == null
|
||||
}
|
||||
}
|
||||
val autoFocusPrimaryField = pages.getOrNull(currentPage)?.autoFocusPrimaryField == true &&
|
||||
isPageTransitionSettled
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
val snackbarAnchors = remember { ExpressiveStepSnackbarAnchors() }
|
||||
var snackbarOverlayBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
var listViewportBoundsForSnackbar by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
@Composable
|
||||
fun BoxScope.ExpressiveStepSnackbarHost() {
|
||||
val bottomPadding by remember(snackbarOverlayBounds, listViewportBoundsForSnackbar) {
|
||||
derivedStateOf {
|
||||
val overlay = snackbarOverlayBounds
|
||||
val anchorTop = snackbarAnchors.anchorTopInWindow(listViewportBoundsForSnackbar)
|
||||
if (overlay == null || anchorTop == null) {
|
||||
ExpressiveStepSnackbarAnchorGap
|
||||
} else {
|
||||
with(density) {
|
||||
(overlay.bottom - anchorTop + ExpressiveStepSnackbarAnchorGap.toPx())
|
||||
.toDp()
|
||||
.coerceAtLeast(ExpressiveStepSnackbarAnchorGap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FromChatSnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = bottomPadding)
|
||||
.fillMaxWidth(),
|
||||
snackbarModifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveStepBottomBar(modifier: Modifier = Modifier) {
|
||||
MorphedExpressiveStepButton(
|
||||
pages = pages,
|
||||
fromIndex = fromIndex,
|
||||
toIndex = toIndex,
|
||||
morphProgress = effectiveMorphProgress,
|
||||
morphing = morphing,
|
||||
settledPage = currentPage,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveHeroSlot(page: Int) {
|
||||
val pageSpec = heroSpecs.getOrElse(page) { heroSpecs.first() }
|
||||
val morphing = fromIndex != toIndex
|
||||
MorphedExpressiveHero(
|
||||
currentSpec = if (morphing) currentSpec else pageSpec,
|
||||
fromSpec = if (morphing) fromSpec else pageSpec,
|
||||
toSpec = if (morphing) toSpec else pageSpec,
|
||||
morphProgress = if (morphing) effectiveMorphProgress else null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExpressiveStepHeroSection(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ExpressiveHeroSlot(currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
val navigateBack: () -> Unit = {
|
||||
if (pagerState.currentPage > 0) {
|
||||
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
|
||||
} else {
|
||||
onBackAtFirstPage()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HazeTopBar(hazeState: HazeState) {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = navigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
trailingTopBarActions?.invoke()
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 1f,
|
||||
endIntensity = 0f,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackButtonRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = navigateBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
trailingTopBarActions?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalExpressiveStepFocusEnabled provides isPageTransitionSettled,
|
||||
LocalExpressiveStepAutoFocusPrimary provides autoFocusPrimaryField,
|
||||
) {
|
||||
if (isHazeLazyMode) {
|
||||
val hazeState = rememberHazeState()
|
||||
val listState = rememberLazyListState()
|
||||
val imeScrollState = rememberLazyListImeScrollState()
|
||||
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = scheme.onSurface,
|
||||
topBar = { HazeTopBar(hazeState = hazeState) },
|
||||
bottomBar = {
|
||||
HazeBottomBar(hazeState = hazeState) {
|
||||
Box(
|
||||
Modifier.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
|
||||
),
|
||||
) {
|
||||
ExpressiveStepBottomBar()
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyListImeScrollEffect(
|
||||
listState = listState,
|
||||
scrollState = imeScrollState,
|
||||
viewportBoundsInWindow = listViewportBounds,
|
||||
contentPaddingTop = innerPadding.calculateTopPadding(),
|
||||
contentPaddingBottom = innerPadding.calculateBottomPadding(),
|
||||
predictiveBackProgress = { flowState.predictiveProgress },
|
||||
)
|
||||
|
||||
DisabledBringIntoViewSpec {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.hazeSource(hazeState)
|
||||
.onGloballyPositioned {
|
||||
listViewportBounds = it.boundsInWindow()
|
||||
listViewportBoundsForSnackbar = it.boundsInWindow()
|
||||
},
|
||||
contentPadding = innerPadding,
|
||||
verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) },
|
||||
) {
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
|
||||
item {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ExpressiveStepHeroSection()
|
||||
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = false,
|
||||
beyondViewportPageCount = 1,
|
||||
pageSpacing = 0.dp,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { page ->
|
||||
Column(modifier = Modifier.pagerPageFullWidth()) {
|
||||
pages[page].content(imeScrollState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentListFooter = pages.getOrNull(currentPage)?.listFooter
|
||||
if (currentListFooter != null) {
|
||||
item(key = "expressive_step_footer_$currentPage") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 4.dp)
|
||||
.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.SecondaryCta,
|
||||
),
|
||||
) {
|
||||
currentListFooter()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top.
|
||||
item(key = "expressive_step_bottom_anchor") {
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveStepSnackbarHost()
|
||||
}
|
||||
} else {
|
||||
val imeScrollState = rememberLazyListImeScrollState()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = scheme.surface,
|
||||
contentColor = scheme.onSurface,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
BackButtonRow()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ExpressiveStepHeroSection()
|
||||
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = false,
|
||||
beyondViewportPageCount = 1,
|
||||
pageSpacing = 0.dp,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { page ->
|
||||
Column(
|
||||
modifier = Modifier.pagerPageFullWidth(),
|
||||
) {
|
||||
pages[page].content(imeScrollState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(top = 12.dp, bottom = 16.dp)
|
||||
.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
|
||||
),
|
||||
) {
|
||||
ExpressiveStepBottomBar()
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveStepSnackbarHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun MorphedExpressiveHero(
|
||||
currentSpec: ExpressiveHeroSpec,
|
||||
fromSpec: ExpressiveHeroSpec,
|
||||
toSpec: ExpressiveHeroSpec,
|
||||
modifier: Modifier = Modifier,
|
||||
containerSize: Dp = 132.dp,
|
||||
iconSize: Dp = 48.dp,
|
||||
morphProgress: Float? = null,
|
||||
) {
|
||||
val usePredictive = morphProgress != null && fromSpec != toSpec
|
||||
val p = (morphProgress ?: 0f).coerceIn(0f, 1f)
|
||||
|
||||
val morph = remember(fromSpec.polygon, toSpec.polygon) {
|
||||
Morph(fromSpec.polygon, toSpec.polygon)
|
||||
}
|
||||
|
||||
val deep = lerp(fromSpec.containerColor, toSpec.containerColor, p)
|
||||
val contentColor = lerp(fromSpec.contentColor, toSpec.contentColor, p)
|
||||
val light = deep.copy(alpha = 0.72f)
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(containerSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val unit = minOf(size.width, size.height) * 0.94f
|
||||
translate(left = size.width / 2f, top = size.height / 2f) {
|
||||
scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) {
|
||||
translate(left = -0.5f, top = -0.5f) {
|
||||
drawPath(
|
||||
path = morph.toPath(p, Path()),
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(light, deep),
|
||||
start = Offset.Zero,
|
||||
end = Offset(1f, 1f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (usePredictive) {
|
||||
Box(
|
||||
modifier = Modifier.size(iconSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = fromSpec.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer { alpha = 1f - p },
|
||||
tint = contentColor,
|
||||
)
|
||||
Icon(
|
||||
imageVector = toSpec.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer { alpha = p },
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = currentSpec.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = currentSpec.contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,15 +21,10 @@ import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun HazeActionButton(
|
||||
onClick: () -> Unit,
|
||||
fun HazeBottomBar(
|
||||
hazeState: HazeState,
|
||||
modifier: Modifier = Modifier,
|
||||
innerModifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable (RowScope.() -> Unit)
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -50,14 +45,31 @@ fun HazeActionButton(
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(top = 0.dp, bottom = 16.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun HazeActionButton(
|
||||
onClick: () -> Unit,
|
||||
hazeState: HazeState,
|
||||
modifier: Modifier = Modifier,
|
||||
innerModifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable (RowScope.() -> Unit)
|
||||
) {
|
||||
HazeBottomBar(hazeState = hazeState, modifier = modifier) {
|
||||
ActionButton(
|
||||
onClick = onClick,
|
||||
modifier = innerModifier,
|
||||
enabled = enabled,
|
||||
loading = loading,
|
||||
interactionSource = interactionSource,
|
||||
content = content
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package ru.fromchat.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
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.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.ImeKeyboardPhase
|
||||
import com.pr0gramm3r101.utils.keyboardPhase
|
||||
import com.pr0gramm3r101.utils.rememberImeMotion
|
||||
import com.pr0gramm3r101.utils.toPx
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Lazy list item indices for [ExpressiveStepFlowScaffold] haze layout. */
|
||||
object ExpressiveStepLazyListIndices {
|
||||
const val TOP_SPACER = 0
|
||||
/** Hero + step [HorizontalPager] scroll together. */
|
||||
const val STEPS_BODY = 1
|
||||
}
|
||||
|
||||
/** When false, step text fields cannot take focus (pager / predictive-back transition in progress). */
|
||||
val LocalExpressiveStepFocusEnabled = compositionLocalOf { true }
|
||||
|
||||
/** When true, the current step's primary field should request focus once. */
|
||||
val LocalExpressiveStepAutoFocusPrimary = compositionLocalOf { false }
|
||||
|
||||
/** Requests focus for [focusRequester] when [LocalExpressiveStepAutoFocusPrimary] becomes true. */
|
||||
@Composable
|
||||
fun ExpressiveStepAutoFocusEffect(focusRequester: FocusRequester) {
|
||||
val autoFocus = LocalExpressiveStepAutoFocusPrimary.current
|
||||
LaunchedEffect(autoFocus) {
|
||||
if (autoFocus) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LazyListImeScrollState internal constructor() {
|
||||
var focusedItemIndex by mutableStateOf<Int?>(null)
|
||||
internal set
|
||||
var focusedBoundsInWindow by mutableStateOf<Rect?>(null)
|
||||
internal set
|
||||
|
||||
internal fun updateFocusedBounds(boundsInWindow: Rect) {
|
||||
focusedBoundsInWindow = boundsInWindow
|
||||
}
|
||||
|
||||
internal fun clearFocusedTarget(itemIndex: Int) {
|
||||
if (focusedItemIndex == itemIndex) {
|
||||
focusedItemIndex = null
|
||||
focusedBoundsInWindow = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records the focused field for [LazyListImeScrollEffect]; does not trigger bring-into-view. */
|
||||
fun Modifier.trackImeScrollTarget(
|
||||
scrollState: LazyListImeScrollState,
|
||||
itemIndex: Int,
|
||||
): Modifier = composed {
|
||||
val focusEnabled = LocalExpressiveStepFocusEnabled.current
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
|
||||
focusProperties {
|
||||
canFocus = focusEnabled
|
||||
}.onFocusChanged { state ->
|
||||
val wasFocused = focused
|
||||
focused = state.isFocused
|
||||
if (state.isFocused) {
|
||||
scrollState.focusedItemIndex = itemIndex
|
||||
} else if (wasFocused) {
|
||||
scrollState.clearFocusedTarget(itemIndex)
|
||||
}
|
||||
}.onGloballyPositioned { coordinates ->
|
||||
if (focused) {
|
||||
scrollState.updateFocusedBounds(coordinates.boundsInWindow())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLazyListImeScrollState(): LazyListImeScrollState =
|
||||
remember { LazyListImeScrollState() }
|
||||
|
||||
private fun effectiveImeBottomPx(
|
||||
reportedImeBottomPx: Int,
|
||||
predictiveBackProgress: Float,
|
||||
): Int {
|
||||
if (reportedImeBottomPx <= 0) return 0
|
||||
val progress = predictiveBackProgress.coerceIn(0f, 1f)
|
||||
if (progress <= 0f) return reportedImeBottomPx
|
||||
return (reportedImeBottomPx * (1f - progress)).roundToInt()
|
||||
}
|
||||
|
||||
/** Scrolls the list when the IME would cover the focused field; skips if already fully visible. */
|
||||
@Composable
|
||||
fun LazyListImeScrollEffect(
|
||||
listState: LazyListState,
|
||||
scrollState: LazyListImeScrollState,
|
||||
viewportBoundsInWindow: Rect?,
|
||||
contentPaddingTop: Dp = 0.dp,
|
||||
contentPaddingBottom: Dp = 0.dp,
|
||||
viewportMargin: Dp = 12.dp,
|
||||
imeScrollEnabled: () -> Boolean = { true },
|
||||
predictiveBackProgress: () -> Float = { 0f },
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val imeMotion = rememberImeMotion()
|
||||
val currentImeScrollEnabled = rememberUpdatedState(imeScrollEnabled)
|
||||
val currentPredictiveProgress = rememberUpdatedState(predictiveBackProgress)
|
||||
val currentFocusedBounds = rememberUpdatedState(scrollState.focusedBoundsInWindow)
|
||||
val previousFollowImeBottom = remember { mutableIntStateOf(-1) }
|
||||
val previousReportedImeBottom = remember { mutableIntStateOf(-1) }
|
||||
val settledImeBottom = remember { mutableIntStateOf(0) }
|
||||
val wasFollowingKeyboardDismiss = remember { mutableStateOf(false) }
|
||||
val dismissFollowKeyboardTopGap = remember { mutableStateOf<Float?>(null) }
|
||||
val skipStableAnchorAfterReopen = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scrollState.focusedItemIndex) {
|
||||
if (scrollState.focusedItemIndex == null) {
|
||||
previousFollowImeBottom.intValue = -1
|
||||
previousReportedImeBottom.intValue = -1
|
||||
settledImeBottom.intValue = 0
|
||||
wasFollowingKeyboardDismiss.value = false
|
||||
dismissFollowKeyboardTopGap.value = null
|
||||
skipStableAnchorAfterReopen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
imeMotion.currentBottomPx,
|
||||
imeMotion.sourceBottomPx,
|
||||
imeMotion.targetBottomPx,
|
||||
predictiveBackProgress(),
|
||||
scrollState.focusedItemIndex,
|
||||
scrollState.focusedBoundsInWindow,
|
||||
viewportBoundsInWindow,
|
||||
contentPaddingTop,
|
||||
contentPaddingBottom,
|
||||
) {
|
||||
if (!currentImeScrollEnabled.value()) return@LaunchedEffect
|
||||
|
||||
val itemIndex = scrollState.focusedItemIndex
|
||||
val viewport = viewportBoundsInWindow
|
||||
val targetBounds = currentFocusedBounds.value
|
||||
|
||||
if (itemIndex == null || viewport == null || targetBounds == null) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val marginPx = viewportMargin.toPx(density)
|
||||
val topInsetPx = contentPaddingTop.toPx(density)
|
||||
val bottomInsetPx = contentPaddingBottom.toPx(density)
|
||||
|
||||
if (listState.layoutInfo.visibleItemsInfo.none { it.index == itemIndex }) {
|
||||
listState.scrollToItem(itemIndex)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val reportedImeBottomPx = imeMotion.currentBottomPx
|
||||
val previousReported = previousReportedImeBottom.intValue
|
||||
val phase = imeMotion.keyboardPhase(
|
||||
settledImeBottomPx = settledImeBottom.intValue,
|
||||
previousReportedImeBottomPx = previousReported,
|
||||
)
|
||||
|
||||
if (!imeMotion.isAnimating) {
|
||||
settledImeBottom.intValue = reportedImeBottomPx
|
||||
}
|
||||
|
||||
when (phase) {
|
||||
ImeKeyboardPhase.ReopeningPartial -> skipStableAnchorAfterReopen.value = true
|
||||
ImeKeyboardPhase.OpeningFromHidden, ImeKeyboardPhase.Hidden -> {
|
||||
skipStableAnchorAfterReopen.value = false
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val progress = currentPredictiveProgress.value().coerceIn(0f, 1f)
|
||||
val followImeBottom = if (progress > 0f) {
|
||||
effectiveImeBottomPx(reportedImeBottomPx, progress)
|
||||
} else {
|
||||
reportedImeBottomPx
|
||||
}
|
||||
|
||||
val followImeDelta = if (previousFollowImeBottom.intValue < 0) {
|
||||
0
|
||||
} else {
|
||||
followImeBottom - previousFollowImeBottom.intValue
|
||||
}
|
||||
|
||||
val fieldBottom = targetBounds.bottom
|
||||
val keyboardTop = keyboardTop(
|
||||
viewport = viewport,
|
||||
followImeBottomPx = followImeBottom,
|
||||
)
|
||||
val shouldStartDismissFollow = followImeDelta < 0 && keyboardTop >= fieldBottom - marginPx
|
||||
|
||||
if (shouldStartDismissFollow || wasFollowingKeyboardDismiss.value) {
|
||||
if (!wasFollowingKeyboardDismiss.value) {
|
||||
dismissFollowKeyboardTopGap.value = keyboardTop - fieldBottom
|
||||
}
|
||||
|
||||
val gap = dismissFollowKeyboardTopGap.value ?: (keyboardTop - fieldBottom)
|
||||
val desiredFieldBottom = keyboardTop - gap
|
||||
val delta = fieldBottom - desiredFieldBottom
|
||||
if (delta != 0f) {
|
||||
listState.scrollBy(delta)
|
||||
}
|
||||
|
||||
wasFollowingKeyboardDismiss.value = true
|
||||
previousFollowImeBottom.intValue = followImeBottom
|
||||
previousReportedImeBottom.intValue = reportedImeBottomPx
|
||||
if (phase == ImeKeyboardPhase.Open || phase == ImeKeyboardPhase.Hidden) {
|
||||
wasFollowingKeyboardDismiss.value = false
|
||||
dismissFollowKeyboardTopGap.value = null
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
previousFollowImeBottom.intValue = followImeBottom
|
||||
|
||||
if (progress > 0f) {
|
||||
previousReportedImeBottom.intValue = reportedImeBottomPx
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val shouldBringIntoView = when (phase) {
|
||||
ImeKeyboardPhase.OpeningFromHidden -> reportedImeBottomPx > 0
|
||||
ImeKeyboardPhase.Hidden -> true
|
||||
ImeKeyboardPhase.Open -> true
|
||||
ImeKeyboardPhase.Closing, ImeKeyboardPhase.ReopeningPartial -> false
|
||||
}
|
||||
|
||||
if (shouldBringIntoView) {
|
||||
if (phase == ImeKeyboardPhase.Open && skipStableAnchorAfterReopen.value) {
|
||||
skipStableAnchorAfterReopen.value = false
|
||||
} else {
|
||||
val delta = listState.measureScrollDelta(
|
||||
targetBoundsInWindow = targetBounds,
|
||||
viewportBoundsInWindow = viewport,
|
||||
contentPaddingTopPx = topInsetPx,
|
||||
contentPaddingBottomPx = bottomInsetPx,
|
||||
viewportMarginPx = marginPx,
|
||||
imeBottomPx = reportedImeBottomPx.toFloat().coerceAtLeast(0f),
|
||||
)
|
||||
if (delta != 0f) {
|
||||
listState.scrollBy(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousReportedImeBottom.intValue = reportedImeBottomPx
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyboardTop(
|
||||
viewport: Rect,
|
||||
followImeBottomPx: Int,
|
||||
): Float = viewport.bottom - followImeBottomPx.toFloat().coerceAtLeast(0f)
|
||||
|
||||
/** Blocks Compose's automatic bring-into-view on focus; pair with [LazyListImeScrollEffect]. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DisabledBringIntoViewSpec(content: @Composable () -> Unit) {
|
||||
CompositionLocalProvider(
|
||||
LocalBringIntoViewSpec provides remember {
|
||||
object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(
|
||||
offset: Float,
|
||||
size: Float,
|
||||
containerSize: Float,
|
||||
): Float = 0f
|
||||
}
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private fun LazyListState.measureScrollDelta(
|
||||
targetBoundsInWindow: Rect,
|
||||
viewportBoundsInWindow: Rect,
|
||||
contentPaddingTopPx: Float,
|
||||
contentPaddingBottomPx: Float,
|
||||
viewportMarginPx: Float,
|
||||
imeBottomPx: Float = 0f,
|
||||
): Float {
|
||||
val viewportTop = viewportBoundsInWindow.top + contentPaddingTopPx + viewportMarginPx
|
||||
val contentVisibleBottom = viewportBoundsInWindow.bottom - contentPaddingBottomPx
|
||||
val keyboardTop = if (imeBottomPx > 0f) {
|
||||
viewportBoundsInWindow.bottom - imeBottomPx
|
||||
} else {
|
||||
contentVisibleBottom
|
||||
}
|
||||
val viewportBottom = minOf(contentVisibleBottom, keyboardTop) - viewportMarginPx
|
||||
|
||||
if (targetBoundsInWindow.top >= viewportTop &&
|
||||
targetBoundsInWindow.bottom <= viewportBottom
|
||||
) {
|
||||
return 0f
|
||||
}
|
||||
|
||||
return if (targetBoundsInWindow.bottom > viewportBottom) {
|
||||
targetBoundsInWindow.bottom - viewportBottom
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun LazyListState.scrollFocusedItemIntoView(
|
||||
itemIndex: Int,
|
||||
viewportMarginPx: Float,
|
||||
) {
|
||||
if (layoutInfo.visibleItemsInfo.none { it.index == itemIndex }) {
|
||||
animateScrollToItem(itemIndex)
|
||||
}
|
||||
|
||||
val viewportStart = layoutInfo.viewportStartOffset + viewportMarginPx
|
||||
val viewportEnd = (layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding - viewportMarginPx)
|
||||
.coerceAtLeast(viewportStart)
|
||||
|
||||
val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemIndex } ?: return
|
||||
|
||||
val itemStart = item.offset.toFloat()
|
||||
val itemEnd = itemStart + item.size
|
||||
|
||||
when {
|
||||
itemStart < viewportStart -> itemStart - viewportStart
|
||||
itemEnd > viewportEnd -> itemEnd - viewportEnd
|
||||
else -> 0f
|
||||
}.also { if (it != 0f) animateScrollBy(it) }
|
||||
}
|
||||
|
||||
// --- Legacy aliases for ServerConfigScreen ---
|
||||
|
||||
typealias LazyListFocusScrollState = LazyListImeScrollState
|
||||
|
||||
@Composable
|
||||
fun rememberLazyListFocusScrollState(): LazyListFocusScrollState =
|
||||
rememberLazyListImeScrollState()
|
||||
|
||||
fun Modifier.trackLazyListFocus(
|
||||
focusState: LazyListFocusScrollState,
|
||||
itemIndex: Int,
|
||||
): Modifier = trackImeScrollTarget(focusState, itemIndex)
|
||||
|
||||
@Composable
|
||||
fun LazyListFocusScrollEffect(
|
||||
listState: LazyListState,
|
||||
focusState: LazyListFocusScrollState,
|
||||
viewportBoundsInWindow: Rect?,
|
||||
contentPaddingTop: Dp = 0.dp,
|
||||
contentPaddingBottom: Dp = 0.dp,
|
||||
viewportMargin: Dp = 12.dp,
|
||||
imeScrollEnabled: () -> Boolean = { true },
|
||||
predictiveBackProgress: () -> Float = { 0f },
|
||||
) {
|
||||
LazyListImeScrollEffect(
|
||||
listState = listState,
|
||||
scrollState = focusState,
|
||||
viewportBoundsInWindow = viewportBoundsInWindow,
|
||||
contentPaddingTop = contentPaddingTop,
|
||||
contentPaddingBottom = contentPaddingBottom,
|
||||
viewportMargin = viewportMargin,
|
||||
imeScrollEnabled = imeScrollEnabled,
|
||||
predictiveBackProgress = predictiveBackProgress,
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package ru.fromchat.ui.components
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarDefaults
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
@@ -35,3 +37,19 @@ fun FromChatSnackbarHost(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses any visible snackbar, then shows [message] without queueing behind it. */
|
||||
suspend fun SnackbarHostState.showReplacingSnackbar(
|
||||
message: String,
|
||||
actionLabel: String? = null,
|
||||
withDismissAction: Boolean = false,
|
||||
duration: SnackbarDuration = SnackbarDuration.Short,
|
||||
): SnackbarResult {
|
||||
currentSnackbarData?.dismiss()
|
||||
return showSnackbar(
|
||||
message = message,
|
||||
actionLabel = actionLabel,
|
||||
withDismissAction = withDismissAction,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ object SettingsRoutes {
|
||||
/** Single destination: in-screen steps + morphing hero (no nested nav routes per step). */
|
||||
const val SecurityPasswordFlow = "settings/security/password"
|
||||
const val Account = "settings/account"
|
||||
const val AccountDeleteFlow = "settings/account/delete"
|
||||
const val ServerConfig = "serverConfig"
|
||||
const val About = "about"
|
||||
}
|
||||
|
||||
+31
-42
@@ -21,8 +21,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
@@ -44,30 +42,30 @@ import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.cancel
|
||||
import ru.fromchat.confirm
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.logout
|
||||
import ru.fromchat.settings_account_delete
|
||||
import ru.fromchat.settings_account_delete_confirm_body
|
||||
import ru.fromchat.settings_account_delete_confirm_title
|
||||
import ru.fromchat.settings_account_delete_d
|
||||
import ru.fromchat.settings_account_logout_confirm_body
|
||||
import ru.fromchat.settings_account_logout_confirm_title
|
||||
import ru.fromchat.settings_account_title
|
||||
import ru.fromchat.settings_change_password
|
||||
import ru.fromchat.settings_security_change_password_sub
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: () -> Unit) {
|
||||
fun AccountScreen(
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onChangePassword: () -> Unit,
|
||||
onDeleteAccount: () -> Unit,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
||||
var showLogoutConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = { Text(stringResource(Res.string.settings_account_title)) },
|
||||
@@ -105,15 +103,18 @@ fun AccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: ()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_account_delete),
|
||||
supportingText = stringResource(Res.string.settings_account_delete_d),
|
||||
onClick = onDeleteAccount,
|
||||
leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
|
||||
divider = true
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.logout),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
runCatching { ApiClient.logout() }
|
||||
WebSocketManager.disconnect()
|
||||
onLogout()
|
||||
}
|
||||
},
|
||||
onClick = { showLogoutConfirm = true },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Logout,
|
||||
@@ -121,48 +122,36 @@ fun AccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: ()
|
||||
Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
divider = true
|
||||
)
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_account_delete),
|
||||
supportingText = stringResource(Res.string.settings_account_delete_d),
|
||||
onClick = { showDeleteConfirm = true },
|
||||
leadingContent = { Icon(Icons.Filled.AccountCircle, null) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
if (showLogoutConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirm = false },
|
||||
title = { Text(stringResource(Res.string.settings_account_delete_confirm_title)) },
|
||||
text = { Text(stringResource(Res.string.settings_account_delete_confirm_body)) },
|
||||
onDismissRequest = { showLogoutConfirm = false },
|
||||
title = { Text(stringResource(Res.string.settings_account_logout_confirm_title)) },
|
||||
text = { Text(stringResource(Res.string.settings_account_logout_confirm_body)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDeleteConfirm = false
|
||||
showLogoutConfirm = false
|
||||
scope.launch {
|
||||
runCatching {
|
||||
ApiClient.deleteAccount()
|
||||
runCatching { ApiClient.logout() }
|
||||
WebSocketManager.disconnect()
|
||||
ApiClient.clearLocalSession()
|
||||
onLogout()
|
||||
}.onFailure {
|
||||
snackbarHostState.showSnackbar(it.message ?: errUnexpected)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(Res.string.confirm))
|
||||
Text(stringResource(Res.string.logout))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
||||
TextButton(onClick = { showLogoutConfirm = false }) {
|
||||
Text(stringResource(Res.string.cancel))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
-892
@@ -1,892 +0,0 @@
|
||||
package ru.fromchat.ui.main.settings.account
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.toPath
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.graphics.shapes.Morph
|
||||
import androidx.graphics.shapes.RoundedPolygon
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import com.pr0gramm3r101.utils.imeScrollWithKeyboard
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.passwords_dont_match
|
||||
import ru.fromchat.settings_change_password
|
||||
import ru.fromchat.settings_confirm_new_password
|
||||
import ru.fromchat.settings_current_password
|
||||
import ru.fromchat.settings_new_password
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.settings_password_changed
|
||||
import ru.fromchat.settings_security_step_confirm_body
|
||||
import ru.fromchat.settings_security_step_confirm_title
|
||||
import ru.fromchat.settings_security_step_current_body
|
||||
import ru.fromchat.settings_security_step_current_title
|
||||
import ru.fromchat.settings_security_step_new_body
|
||||
import ru.fromchat.settings_security_step_new_title
|
||||
import ru.fromchat.ui.components.CtaShape
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
/** Outline shape for password step text fields. */
|
||||
val SettingsPasswordOutlineFieldShape = RoundedCornerShape(18.dp)
|
||||
|
||||
/** Steps in the change-password flow (single navigation destination; drives hero morph + form slide). */
|
||||
enum class SecurityPasswordFlowStep {
|
||||
Current,
|
||||
New,
|
||||
Confirm,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromOrdinal(ordinal: Int): SecurityPasswordFlowStep =
|
||||
entries.getOrElse(ordinal.coerceIn(0, entries.lastIndex)) { Current }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Predefined [RoundedPolygon]s from the Material 3 shape library ([MaterialShapes]), one per step.
|
||||
* Uses the same “cookie” family so morphs stay subtle between steps.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
fun securityHeroMaterialPolygon(step: SecurityPasswordFlowStep): RoundedPolygon =
|
||||
when (step) {
|
||||
SecurityPasswordFlowStep.Current -> MaterialShapes.Cookie4Sided
|
||||
SecurityPasswordFlowStep.New -> MaterialShapes.Cookie6Sided
|
||||
SecurityPasswordFlowStep.Confirm -> MaterialShapes.Cookie7Sided
|
||||
}.normalized()
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberSecurityPasswordHeroMorph(step: SecurityPasswordFlowStep): Pair<Morph, Animatable<Float, *>> {
|
||||
var fromStep by remember { mutableStateOf(step) }
|
||||
var toStep by remember { mutableStateOf(step) }
|
||||
val progress = remember { Animatable(1f) }
|
||||
LaunchedEffect(step) {
|
||||
if (step != toStep) {
|
||||
fromStep = toStep
|
||||
toStep = step
|
||||
progress.snapTo(0f)
|
||||
progress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(dampingRatio = 0.82f, stiffness = 380f),
|
||||
)
|
||||
}
|
||||
}
|
||||
val morph = remember(fromStep, toStep) {
|
||||
Morph(securityHeroMaterialPolygon(fromStep), securityHeroMaterialPolygon(toStep))
|
||||
}
|
||||
return morph to progress
|
||||
}
|
||||
|
||||
private object SecurityPasswordDraft {
|
||||
var current: String = ""
|
||||
var newPassword: String = ""
|
||||
var confirmPassword: String = ""
|
||||
|
||||
fun clear() {
|
||||
current = ""
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityPasswordOutlinedField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
colors: TextFieldColors,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = label,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
colors = colors,
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun SettingsSecurityPasswordFlowScreen(onBack: () -> Unit, onDonePopToHub: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var current by remember { mutableStateOf("") }
|
||||
var newP by remember { mutableStateOf("") }
|
||||
var confirmP by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val showSnack = { text: String ->
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = text,
|
||||
withDismissAction = false,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val pwdLen = stringResource(Res.string.password_length_error)
|
||||
val pwdMatch = stringResource(Res.string.passwords_dont_match)
|
||||
val okMsg = stringResource(Res.string.settings_password_changed)
|
||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val username = ApiClient.user?.username.orEmpty()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
SecurityPasswordDraft.clear()
|
||||
current = ""
|
||||
newP = ""
|
||||
confirmP = ""
|
||||
busy = false
|
||||
}
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
pageCount = { SecurityPasswordFlowStep.entries.size },
|
||||
)
|
||||
|
||||
val step = SecurityPasswordFlowStep.fromOrdinal(pagerState.currentPage)
|
||||
val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction }
|
||||
|
||||
var predictiveFromStep by remember { mutableStateOf<SecurityPasswordFlowStep?>(null) }
|
||||
var predictiveToStep by remember { mutableStateOf<SecurityPasswordFlowStep?>(null) }
|
||||
var predictiveProgress by remember { mutableFloatStateOf(0f) }
|
||||
val predictiveThreshold = 0.15f
|
||||
|
||||
PredictiveBackHandler(
|
||||
enabled = pagerState.currentPage > 0,
|
||||
onProgress = { p ->
|
||||
val clamped = p.coerceIn(0f, 1f)
|
||||
if (clamped <= 0f) {
|
||||
predictiveProgress = 0f
|
||||
predictiveFromStep = null
|
||||
predictiveToStep = null
|
||||
} else {
|
||||
if (predictiveFromStep == null || predictiveToStep == null) {
|
||||
val fromPage = pagerState.currentPage
|
||||
val toPage = (fromPage - 1).coerceAtLeast(0)
|
||||
predictiveFromStep = SecurityPasswordFlowStep.fromOrdinal(fromPage)
|
||||
predictiveToStep = SecurityPasswordFlowStep.fromOrdinal(toPage)
|
||||
}
|
||||
predictiveProgress = clamped
|
||||
}
|
||||
},
|
||||
onCommit = {
|
||||
val fromStepSnapshot = predictiveFromStep
|
||||
val toStepSnapshot = predictiveToStep
|
||||
val lastProgress = predictiveProgress.coerceIn(0f, 1f)
|
||||
// If progress is below threshold, treat as cancel and animate back to 0.
|
||||
if (lastProgress < predictiveThreshold || fromStepSnapshot == null || toStepSnapshot == null) {
|
||||
scope.launch {
|
||||
Animatable(lastProgress).animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
) {
|
||||
predictiveProgress = value
|
||||
}
|
||||
val fromPage = fromStepSnapshot?.ordinal ?: pagerState.currentPage
|
||||
pagerState.scrollToPage(fromPage)
|
||||
predictiveFromStep = null
|
||||
predictiveToStep = null
|
||||
predictiveProgress = 0f
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Animatable(lastProgress).animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
) {
|
||||
predictiveProgress = value
|
||||
}
|
||||
pagerState.scrollToPage(toStepSnapshot.ordinal)
|
||||
predictiveFromStep = null
|
||||
predictiveToStep = null
|
||||
predictiveProgress = 0f
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = {
|
||||
val fromStepSnapshot = predictiveFromStep
|
||||
val lastProgress = predictiveProgress.coerceIn(0f, 1f)
|
||||
if (fromStepSnapshot == null) {
|
||||
predictiveProgress = 0f
|
||||
predictiveFromStep = null
|
||||
predictiveToStep = null
|
||||
return@PredictiveBackHandler
|
||||
}
|
||||
scope.launch {
|
||||
val target = if (lastProgress >= predictiveThreshold) 1f else 0f
|
||||
Animatable(lastProgress).animateTo(
|
||||
targetValue = target,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
) {
|
||||
predictiveProgress = value
|
||||
}
|
||||
val commit = lastProgress >= predictiveThreshold
|
||||
val targetPage = if (commit) {
|
||||
(fromStepSnapshot.ordinal - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
fromStepSnapshot.ordinal
|
||||
}
|
||||
pagerState.scrollToPage(targetPage)
|
||||
predictiveFromStep = null
|
||||
predictiveToStep = null
|
||||
predictiveProgress = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(predictiveFromStep, predictiveProgress) {
|
||||
val fromStepForPager = predictiveFromStep ?: return@LaunchedEffect
|
||||
val toStepForPager = predictiveToStep ?: return@LaunchedEffect
|
||||
val clamped = predictiveProgress.coerceIn(0f, 1f)
|
||||
if (clamped <= 0f) return@LaunchedEffect
|
||||
val page: Int
|
||||
val offset: Float
|
||||
if (clamped <= 0.5f) {
|
||||
page = fromStepForPager.ordinal
|
||||
offset = (-clamped).coerceIn(-0.5f, 0f)
|
||||
} else {
|
||||
page = toStepForPager.ordinal
|
||||
offset = (1f - clamped).coerceIn(0f, 0.5f)
|
||||
}
|
||||
pagerState.scrollToPage(
|
||||
page = page,
|
||||
pageOffsetFraction = offset,
|
||||
)
|
||||
}
|
||||
|
||||
val lastIndex = SecurityPasswordFlowStep.entries.lastIndex
|
||||
val fromIndex: Int
|
||||
val toIndex: Int
|
||||
val morphProgress: Float
|
||||
if (predictiveFromStep != null && predictiveToStep != null && predictiveProgress > 0f) {
|
||||
fromIndex = predictiveFromStep!!.ordinal
|
||||
toIndex = predictiveToStep!!.ordinal
|
||||
morphProgress = predictiveProgress
|
||||
} else if (pageOffset < 0f) {
|
||||
fromIndex = pagerState.currentPage
|
||||
toIndex = (fromIndex - 1).coerceAtLeast(0)
|
||||
morphProgress = -pageOffset
|
||||
} else if (pageOffset > 0f) {
|
||||
fromIndex = pagerState.currentPage
|
||||
toIndex = (fromIndex + 1).coerceAtMost(lastIndex)
|
||||
morphProgress = pageOffset
|
||||
} else {
|
||||
fromIndex = pagerState.currentPage
|
||||
toIndex = fromIndex
|
||||
morphProgress = 0f
|
||||
}
|
||||
val fromStep = SecurityPasswordFlowStep.fromOrdinal(fromIndex)
|
||||
val toStep = SecurityPasswordFlowStep.fromOrdinal(toIndex)
|
||||
val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f)
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val passwordFieldColors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = scheme.onSurface,
|
||||
unfocusedTextColor = scheme.onSurface,
|
||||
disabledTextColor = scheme.onSurface.copy(alpha = 0.38f),
|
||||
focusedLabelColor = scheme.primary,
|
||||
unfocusedLabelColor = scheme.onSurfaceVariant,
|
||||
disabledLabelColor = scheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
cursorColor = scheme.primary,
|
||||
focusedBorderColor = scheme.primary,
|
||||
unfocusedBorderColor = scheme.outline,
|
||||
disabledBorderColor = scheme.onSurface.copy(alpha = 0.12f),
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = scheme.surface,
|
||||
contentColor = scheme.onSurface,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.imeScrollWithKeyboard()
|
||||
.statusBarsPadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(40.dp))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
SettingsSecurityMorphedPasswordHero(
|
||||
step = step,
|
||||
predictiveProgress = if (fromStep != toStep) effectiveMorphProgress else null,
|
||||
predictiveFromStep = if (fromStep != toStep) fromStep else null,
|
||||
predictiveToStep = if (fromStep != toStep) toStep else null,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Pager layout height is max(cross-axis size) of composed pages (visible + beyondViewport).
|
||||
// Default beyondViewport is small, so after predictive back only page 0 may be measured and
|
||||
// the slot height can shrink vs the two-page gesture — content jumps up. Composing neighbors
|
||||
// on both sides keeps max height stable across steps without visiting the last page first.
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = false,
|
||||
beyondViewportPageCount = SecurityPasswordFlowStep.entries.lastIndex,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { page ->
|
||||
val pageStep = SecurityPasswordFlowStep.fromOrdinal(page)
|
||||
Box(Modifier.padding(horizontal = SettingsStepHorizontalPadding)) {
|
||||
SecurityPasswordStepPage(
|
||||
step = pageStep,
|
||||
scheme = scheme,
|
||||
passwordFieldColors = passwordFieldColors,
|
||||
current = current,
|
||||
onCurrentChange = { current = it },
|
||||
newP = newP,
|
||||
onNewPChange = { newP = it },
|
||||
confirmP = confirmP,
|
||||
onConfirmPChange = { confirmP = it },
|
||||
busy = busy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (pagerState.currentPage > 0) {
|
||||
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.statusBarsPadding()
|
||||
.padding(start = 4.dp, top = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(top = 12.dp, bottom = 16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
when (step) {
|
||||
SecurityPasswordFlowStep.Current -> {
|
||||
Button(
|
||||
onClick = {
|
||||
if (current.isBlank()) {
|
||||
showSnack(fillAll)
|
||||
return@Button
|
||||
}
|
||||
SecurityPasswordDraft.current = current
|
||||
scope.launch { pagerState.animateScrollToPage(1) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp),
|
||||
shape = CtaShape,
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp,
|
||||
disabledElevation = 0.dp,
|
||||
),
|
||||
) {
|
||||
PasswordFlowBottomButtonText(stringResource(Res.string.settings_next))
|
||||
}
|
||||
}
|
||||
|
||||
SecurityPasswordFlowStep.New -> {
|
||||
Button(
|
||||
onClick = {
|
||||
if (newP.length !in 5..50) {
|
||||
showSnack(pwdLen)
|
||||
return@Button
|
||||
}
|
||||
SecurityPasswordDraft.newPassword = newP
|
||||
scope.launch { pagerState.animateScrollToPage(2) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp),
|
||||
shape = CtaShape,
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp,
|
||||
disabledElevation = 0.dp,
|
||||
),
|
||||
) {
|
||||
PasswordFlowBottomButtonText(stringResource(Res.string.settings_next))
|
||||
}
|
||||
}
|
||||
|
||||
SecurityPasswordFlowStep.Confirm -> {
|
||||
Button(
|
||||
onClick = {
|
||||
if (confirmP.isBlank()) {
|
||||
showSnack(fillAll)
|
||||
return@Button
|
||||
}
|
||||
if (SecurityPasswordDraft.newPassword != confirmP) {
|
||||
showSnack(pwdMatch)
|
||||
return@Button
|
||||
}
|
||||
if (SecurityPasswordDraft.newPassword.length !in 5..50) {
|
||||
showSnack(pwdLen)
|
||||
return@Button
|
||||
}
|
||||
if (username.isBlank()) {
|
||||
showSnack(errUnexpected)
|
||||
return@Button
|
||||
}
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching {
|
||||
val curD = deriveAuthSecret(username, SecurityPasswordDraft.current)
|
||||
val newD = deriveAuthSecret(username, SecurityPasswordDraft.newPassword)
|
||||
ApiClient.changePassword(curD, newD, true)
|
||||
}.onSuccess {
|
||||
SecurityPasswordDraft.clear()
|
||||
showSnack(okMsg)
|
||||
onDonePopToHub()
|
||||
}.onFailure { e ->
|
||||
val msg = (e as? ClientRequestException)?.response?.let { "Error ${it.status.value}" }
|
||||
?: e.message
|
||||
?: errUnexpected
|
||||
showSnack(msg)
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp),
|
||||
shape = CtaShape,
|
||||
elevation = ButtonDefaults.buttonElevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp,
|
||||
disabledElevation = 0.dp,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = busy,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith
|
||||
(fadeOut(tween(200)) + slideOutVertically { -it / 4 })
|
||||
},
|
||||
label = "change_password_cta"
|
||||
) { loading ->
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
PasswordFlowBottomButtonText(stringResource(Res.string.settings_change_password))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FromChatSnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = 76.dp)
|
||||
.fillMaxWidth(),
|
||||
snackbarModifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordFlowBottomButtonText(text: String) {
|
||||
AnimatedContent(
|
||||
targetState = text,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(220)) + slideInVertically { it / 3 }) togetherWith
|
||||
(fadeOut(tween(220)) + slideOutVertically { -it / 3 })
|
||||
},
|
||||
label = "settings_password_button_text"
|
||||
) { label ->
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecurityPasswordStepPage(
|
||||
step: SecurityPasswordFlowStep,
|
||||
scheme: androidx.compose.material3.ColorScheme,
|
||||
passwordFieldColors: TextFieldColors,
|
||||
current: String,
|
||||
onCurrentChange: (String) -> Unit,
|
||||
newP: String,
|
||||
onNewPChange: (String) -> Unit,
|
||||
confirmP: String,
|
||||
onConfirmPChange: (String) -> Unit,
|
||||
busy: Boolean,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when (step) {
|
||||
SecurityPasswordFlowStep.Current -> {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_security_step_current_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_security_step_current_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SecurityPasswordOutlinedField(
|
||||
value = current,
|
||||
onValueChange = onCurrentChange,
|
||||
label = { Text(stringResource(Res.string.settings_current_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = passwordFieldColors,
|
||||
)
|
||||
}
|
||||
|
||||
SecurityPasswordFlowStep.New -> {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_security_step_new_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_security_step_new_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SecurityPasswordOutlinedField(
|
||||
value = newP,
|
||||
onValueChange = onNewPChange,
|
||||
label = { Text(stringResource(Res.string.settings_new_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = passwordFieldColors,
|
||||
)
|
||||
}
|
||||
|
||||
SecurityPasswordFlowStep.Confirm -> {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_security_step_confirm_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_security_step_confirm_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SecurityPasswordOutlinedField(
|
||||
value = confirmP,
|
||||
onValueChange = onConfirmPChange,
|
||||
label = { Text(stringResource(Res.string.settings_confirm_new_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !busy,
|
||||
colors = passwordFieldColors,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Large step icon with gradient fill; **shape morphs** between library polygons ([MaterialShapes] via [Morph]),
|
||||
* icon **crossfades**.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun SettingsSecurityMorphedPasswordHero(
|
||||
step: SecurityPasswordFlowStep,
|
||||
modifier: Modifier = Modifier,
|
||||
containerSize: Dp = 132.dp,
|
||||
iconSize: Dp = 48.dp,
|
||||
predictiveProgress: Float? = null,
|
||||
predictiveFromStep: SecurityPasswordFlowStep? = null,
|
||||
predictiveToStep: SecurityPasswordFlowStep? = null,
|
||||
) {
|
||||
val usePredictive = predictiveProgress != null && predictiveFromStep != null && predictiveToStep != null
|
||||
|
||||
val fromStep = when {
|
||||
usePredictive -> predictiveFromStep
|
||||
else -> step
|
||||
}
|
||||
val toStep = when {
|
||||
usePredictive -> predictiveToStep
|
||||
else -> step
|
||||
}
|
||||
|
||||
val morph = remember(fromStep, toStep) {
|
||||
Morph(
|
||||
securityHeroMaterialPolygon(fromStep),
|
||||
securityHeroMaterialPolygon(toStep),
|
||||
)
|
||||
}
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val fromContainer = when (fromStep) {
|
||||
SecurityPasswordFlowStep.Current -> scheme.secondaryContainer
|
||||
SecurityPasswordFlowStep.New -> scheme.tertiaryContainer
|
||||
SecurityPasswordFlowStep.Confirm -> scheme.primaryContainer
|
||||
}
|
||||
val toContainer = when (toStep) {
|
||||
SecurityPasswordFlowStep.Current -> scheme.secondaryContainer
|
||||
SecurityPasswordFlowStep.New -> scheme.tertiaryContainer
|
||||
SecurityPasswordFlowStep.Confirm -> scheme.primaryContainer
|
||||
}
|
||||
val fromContent = when (fromStep) {
|
||||
SecurityPasswordFlowStep.Current -> scheme.onSecondaryContainer
|
||||
SecurityPasswordFlowStep.New -> scheme.onTertiaryContainer
|
||||
SecurityPasswordFlowStep.Confirm -> scheme.onPrimaryContainer
|
||||
}
|
||||
val toContent = when (toStep) {
|
||||
SecurityPasswordFlowStep.Current -> scheme.onSecondaryContainer
|
||||
SecurityPasswordFlowStep.New -> scheme.onTertiaryContainer
|
||||
SecurityPasswordFlowStep.Confirm -> scheme.onPrimaryContainer
|
||||
}
|
||||
|
||||
val p = (predictiveProgress ?: 0f).coerceIn(0f, 1f)
|
||||
val deep = lerp(fromContainer, toContainer, p)
|
||||
val contentColor = lerp(fromContent, toContent, p)
|
||||
val light = deep.copy(alpha = 0.72f)
|
||||
|
||||
Box(
|
||||
modifier = modifier.size(containerSize),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val unit = minOf(size.width, size.height) * 0.94f
|
||||
|
||||
translate(left = size.width / 2f, top = size.height / 2f) {
|
||||
scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) {
|
||||
translate(left = -0.5f, top = -0.5f) {
|
||||
drawPath(
|
||||
path = morph.toPath(p, Path()),
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(light, deep),
|
||||
start = Offset.Zero,
|
||||
end = Offset(1f, 1f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fromIcon = when (fromStep) {
|
||||
SecurityPasswordFlowStep.Current -> Icons.Filled.Key
|
||||
SecurityPasswordFlowStep.New -> Icons.Filled.Lock
|
||||
SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser
|
||||
}
|
||||
|
||||
val toIcon = when (toStep) {
|
||||
SecurityPasswordFlowStep.Current -> Icons.Filled.Key
|
||||
SecurityPasswordFlowStep.New -> Icons.Filled.Lock
|
||||
SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser
|
||||
}
|
||||
|
||||
if (usePredictive && fromStep != toStep) {
|
||||
Box(
|
||||
modifier = Modifier.size(iconSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = fromIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer { alpha = 1f - p },
|
||||
tint = contentColor,
|
||||
)
|
||||
Icon(
|
||||
imageVector = toIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer { alpha = p },
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(
|
||||
targetState = step,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(220)) togetherWith fadeOut(tween(180))
|
||||
},
|
||||
label = "security_hero_icon"
|
||||
) { s ->
|
||||
val icon = when (s) {
|
||||
SecurityPasswordFlowStep.Current -> Icons.Filled.Key
|
||||
SecurityPasswordFlowStep.New -> Icons.Filled.Lock
|
||||
SecurityPasswordFlowStep.Confirm -> Icons.Filled.VerifiedUser
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package ru.fromchat.ui.main.settings.account.changepassword
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||
|
||||
private enum class ChangePasswordFlowStep {
|
||||
Current,
|
||||
New,
|
||||
Confirm,
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChangePasswordScreen(onBack: () -> Unit, onDone: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val flowState = rememberExpressiveStepFlow(ChangePasswordFlowStep.entries.size)
|
||||
|
||||
var currentPassword by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
|
||||
fun showSnack(text: String) {
|
||||
scope.launch {
|
||||
snackbarHostState.showReplacingSnackbar(
|
||||
message = text,
|
||||
withDismissAction = false,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
currentPassword = ""
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
}
|
||||
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
currentPasswordStepPage(
|
||||
currentPassword = currentPassword,
|
||||
onCurrentPasswordChange = { currentPassword = it },
|
||||
onContinue = { flowState.pagerState.animateScrollToPage(1) },
|
||||
onSnackbar = ::showSnack,
|
||||
),
|
||||
newPasswordStepPage(
|
||||
newPassword = newPassword,
|
||||
onNewPasswordChange = { newPassword = it },
|
||||
onContinue = { flowState.pagerState.animateScrollToPage(2) },
|
||||
onSnackbar = ::showSnack,
|
||||
),
|
||||
confirmPasswordStepPage(
|
||||
currentPassword = currentPassword,
|
||||
newPassword = newPassword,
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
onDone = onDone,
|
||||
onSnackbar = ::showSnack,
|
||||
),
|
||||
),
|
||||
snackbarHostState = snackbarHostState,
|
||||
onBackAtFirstPage = onBack,
|
||||
)
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package ru.fromchat.ui.main.settings.account.changepassword
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.passwords_dont_match
|
||||
import ru.fromchat.settings_change_password
|
||||
import ru.fromchat.settings_confirm_new_password
|
||||
import ru.fromchat.settings_password_changed
|
||||
import ru.fromchat.settings_security_step_confirm_body
|
||||
import ru.fromchat.settings_security_step_confirm_title
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun confirmPasswordStepPage(
|
||||
currentPassword: String,
|
||||
newPassword: String,
|
||||
confirmPassword: String,
|
||||
onConfirmPasswordChange: (String) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val username = ApiClient.user?.username.orEmpty()
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val pwdLen = stringResource(Res.string.password_length_error)
|
||||
val pwdMatch = stringResource(Res.string.passwords_dont_match)
|
||||
val okMsg = stringResource(Res.string.settings_password_changed)
|
||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val changeLabel = stringResource(Res.string.settings_change_password)
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.VerifiedUser,
|
||||
polygon = MaterialShapes.Cookie7Sided.normalized(),
|
||||
containerColor = scheme.primaryContainer,
|
||||
contentColor = scheme.onPrimaryContainer,
|
||||
),
|
||||
content = { imeScroll ->
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.settings_security_step_confirm_title),
|
||||
body = stringResource(Res.string.settings_security_step_confirm_body),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = onConfirmPasswordChange,
|
||||
label = { Text(stringResource(Res.string.settings_confirm_new_password)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
enabled = !busy,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
|
||||
if (confirmPassword.isBlank()) {
|
||||
onSnackbar(fillAll)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
if (newPassword != confirmPassword) {
|
||||
onSnackbar(pwdMatch)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
if (newPassword.length !in 5..50) {
|
||||
onSnackbar(pwdLen)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
if (username.isBlank()) {
|
||||
onSnackbar(errUnexpected)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
ApiClient.changePassword(
|
||||
deriveAuthSecret(username, currentPassword),
|
||||
deriveAuthSecret(username, newPassword),
|
||||
true
|
||||
)
|
||||
|
||||
onSnackbar(okMsg)
|
||||
onDone()
|
||||
} catch (e: Exception) {
|
||||
onSnackbar(
|
||||
(e as? ClientRequestException)?.response?.let {
|
||||
"Error ${it.status.value}"
|
||||
} ?: e.message ?: errUnexpected
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(changeLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package ru.fromchat.ui.main.settings.account.changepassword
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.auth_wrong_password
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.settings_current_password
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.settings_security_step_current_body
|
||||
import ru.fromchat.settings_security_step_current_title
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
private class WrongCurrentPasswordException : Exception()
|
||||
|
||||
private suspend fun verifyCurrentPassword(passwordDerived: String) {
|
||||
try {
|
||||
ApiClient.verifyPasswordDerived(passwordDerived)
|
||||
} catch (e: ClientRequestException) {
|
||||
if (e.response.status.value == 400) {
|
||||
throw WrongCurrentPasswordException()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentPasswordVerifyErrorMessage(
|
||||
error: Throwable,
|
||||
wrongPassword: String,
|
||||
errUnexpected: String,
|
||||
) = when (error) {
|
||||
is WrongCurrentPasswordException -> wrongPassword
|
||||
is ClientRequestException -> "Error ${error.response.status.value}"
|
||||
else -> error.message ?: errUnexpected
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun currentPasswordStepPage(
|
||||
currentPassword: String,
|
||||
onCurrentPasswordChange: (String) -> Unit,
|
||||
onContinue: suspend () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val wrongPassword = stringResource(Res.string.auth_wrong_password)
|
||||
val nextLabel = stringResource(Res.string.settings_next)
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Key,
|
||||
polygon = MaterialShapes.Cookie4Sided.normalized(),
|
||||
containerColor = scheme.secondaryContainer,
|
||||
contentColor = scheme.onSecondaryContainer,
|
||||
),
|
||||
content = { imeScroll ->
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.settings_security_step_current_title),
|
||||
body = stringResource(Res.string.settings_security_step_current_body),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentPassword,
|
||||
onValueChange = onCurrentPasswordChange,
|
||||
label = { Text(stringResource(Res.string.settings_current_password)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
enabled = !busy,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
|
||||
if (currentPassword.isBlank()) {
|
||||
onSnackbar(fillAll)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
val username = ApiClient.user?.username.orEmpty()
|
||||
if (username.isBlank()) {
|
||||
onSnackbar(errUnexpected)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
verifyCurrentPassword(deriveAuthSecret(username, currentPassword))
|
||||
onContinue()
|
||||
} catch (e: Exception) {
|
||||
onSnackbar(currentPasswordVerifyErrorMessage(e, wrongPassword, errUnexpected))
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(nextLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package ru.fromchat.ui.main.settings.account.changepassword
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.password_length_error
|
||||
import ru.fromchat.settings_new_password
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.settings_security_step_new_body
|
||||
import ru.fromchat.settings_security_step_new_title
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun newPasswordStepPage(
|
||||
newPassword: String,
|
||||
onNewPasswordChange: (String) -> Unit,
|
||||
onContinue: suspend () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val pwdLen = stringResource(Res.string.password_length_error)
|
||||
val nextLabel = stringResource(Res.string.settings_next)
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Lock,
|
||||
polygon = MaterialShapes.Cookie6Sided.normalized(),
|
||||
containerColor = scheme.tertiaryContainer,
|
||||
contentColor = scheme.onTertiaryContainer,
|
||||
),
|
||||
content = { imeScroll ->
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.settings_security_step_new_title),
|
||||
body = stringResource(Res.string.settings_security_step_new_body),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = newPassword,
|
||||
onValueChange = onNewPasswordChange,
|
||||
label = { Text(stringResource(Res.string.settings_new_password)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (newPassword.length !in 5..50) {
|
||||
onSnackbar(pwdLen)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch { onContinue() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(nextLabel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package ru.fromchat.ui.main.settings.account.delete
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.rounded.AlternateEmail
|
||||
import androidx.compose.material.icons.rounded.Block
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.PersonOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.settings_delete_consequence_chat_history
|
||||
import ru.fromchat.settings_delete_consequence_messages
|
||||
import ru.fromchat.settings_delete_consequence_permanent
|
||||
import ru.fromchat.settings_delete_consequence_username
|
||||
import ru.fromchat.settings_delete_step_intro_body
|
||||
import ru.fromchat.settings_delete_step_intro_title
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun consequencesStepPage(
|
||||
onContinue: suspend () -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.DeleteForever,
|
||||
polygon = MaterialShapes.Cookie4Sided.normalized(),
|
||||
containerColor = scheme.errorContainer,
|
||||
contentColor = scheme.onErrorContainer,
|
||||
),
|
||||
content = {
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.settings_delete_step_intro_title),
|
||||
body = stringResource(Res.string.settings_delete_step_intro_body),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Category(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
margin = PaddingValues(horizontal = SettingsStepHorizontalPadding),
|
||||
containerColor = scheme.surfaceContainerLow,
|
||||
) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_delete_consequence_chat_history),
|
||||
leadingContent = {
|
||||
Icon(Icons.Rounded.Delete, null)
|
||||
},
|
||||
divider = true,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_delete_consequence_username),
|
||||
leadingContent = {
|
||||
Icon(Icons.Rounded.AlternateEmail, null)
|
||||
},
|
||||
divider = true,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_delete_consequence_messages),
|
||||
leadingContent = {
|
||||
Icon(Icons.Rounded.PersonOff, null)
|
||||
},
|
||||
divider = true,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_delete_consequence_permanent),
|
||||
leadingContent = {
|
||||
Icon(Icons.Rounded.Block, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = { scope.launch { onContinue() } },
|
||||
destructive = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_next))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package ru.fromchat.ui.main.settings.account.delete
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||
|
||||
private enum class DeleteAccountFlowStep {
|
||||
Consequences,
|
||||
Password,
|
||||
Final,
|
||||
}
|
||||
|
||||
internal object DeleteAccountDraft {
|
||||
var passwordDerived: String = ""
|
||||
|
||||
fun clear() {
|
||||
passwordDerived = ""
|
||||
}
|
||||
}
|
||||
|
||||
private class DeleteAccountWrongPasswordException : Exception()
|
||||
|
||||
internal suspend fun verifyPasswordForDeletion(passwordDerived: String) {
|
||||
try {
|
||||
ApiClient.verifyPasswordDerived(passwordDerived)
|
||||
} catch (e: ClientRequestException) {
|
||||
if (e.response.status.value == 400) {
|
||||
throw DeleteAccountWrongPasswordException()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun deleteAccountWithDerivedPassword(passwordDerived: String) {
|
||||
ApiClient.deleteAccount(passwordDerived)
|
||||
}
|
||||
|
||||
internal fun passwordVerifyErrorMessage(
|
||||
error: Throwable,
|
||||
wrongPassword: String,
|
||||
errUnexpected: String,
|
||||
) = when (error) {
|
||||
is DeleteAccountWrongPasswordException -> wrongPassword
|
||||
is ClientRequestException -> "Error ${error.response.status.value}"
|
||||
else -> error.message ?: errUnexpected
|
||||
}
|
||||
|
||||
internal fun deleteErrorMessage(error: Throwable, errUnexpected: String) =
|
||||
(error as? ClientRequestException)?.response?.let { "Error ${it.status.value}" }
|
||||
?: error.message
|
||||
?: errUnexpected
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun DeleteAccountScreen(onBack: () -> Unit, onDeleted: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val flowState = rememberExpressiveStepFlow(DeleteAccountFlowStep.entries.size)
|
||||
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmationPhrase by remember { mutableStateOf("") }
|
||||
|
||||
fun showSnack(text: String) {
|
||||
scope.launch {
|
||||
snackbarHostState.showReplacingSnackbar(
|
||||
message = text,
|
||||
withDismissAction = false,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
DeleteAccountDraft.clear()
|
||||
password = ""
|
||||
confirmationPhrase = ""
|
||||
}
|
||||
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
consequencesStepPage(
|
||||
onContinue = { flowState.pagerState.animateScrollToPage(1) },
|
||||
),
|
||||
passwordVerifyStepPage(
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
onContinue = { derived ->
|
||||
DeleteAccountDraft.passwordDerived = derived
|
||||
flowState.pagerState.animateScrollToPage(2)
|
||||
},
|
||||
onSnackbar = ::showSnack,
|
||||
),
|
||||
finalWarningStepPage(
|
||||
confirmationPhrase = confirmationPhrase,
|
||||
onConfirmationPhraseChange = { confirmationPhrase = it },
|
||||
passwordDerived = DeleteAccountDraft.passwordDerived,
|
||||
onDeleted = {
|
||||
scope.launch {
|
||||
WebSocketManager.disconnect()
|
||||
ApiClient.clearLocalSession()
|
||||
DeleteAccountDraft.clear()
|
||||
onDeleted()
|
||||
}
|
||||
},
|
||||
onSnackbar = ::showSnack,
|
||||
),
|
||||
),
|
||||
snackbarHostState = snackbarHostState,
|
||||
onBackAtFirstPage = onBack,
|
||||
)
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package ru.fromchat.ui.main.settings.account.delete
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.settings_delete_account_button
|
||||
import ru.fromchat.settings_delete_confirm_phrase
|
||||
import ru.fromchat.settings_delete_confirm_phrase_instruction
|
||||
import ru.fromchat.settings_delete_confirm_phrase_quote
|
||||
import ru.fromchat.settings_delete_step_final_body
|
||||
import ru.fromchat.settings_delete_step_final_title
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun finalWarningStepPage(
|
||||
confirmationPhrase: String,
|
||||
onConfirmationPhraseChange: (String) -> Unit,
|
||||
passwordDerived: String,
|
||||
onDeleted: () -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val requiredPhrase = stringResource(Res.string.settings_delete_confirm_phrase)
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val phraseMatches = confirmationPhrase.equals(requiredPhrase, ignoreCase = true)
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Warning,
|
||||
polygon = MaterialShapes.Cookie7Sided.normalized(),
|
||||
containerColor = scheme.tertiaryContainer,
|
||||
contentColor = scheme.onTertiaryContainer,
|
||||
),
|
||||
content = { imeScroll ->
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.settings_delete_step_final_title),
|
||||
body = stringResource(Res.string.settings_delete_step_final_body),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.settings_delete_confirm_phrase_instruction,
|
||||
stringResource(Res.string.settings_delete_confirm_phrase_quote),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = scheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(top = 4.dp, bottom = 12.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmationPhrase,
|
||||
onValueChange = onConfirmationPhraseChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
enabled = !busy,
|
||||
singleLine = true,
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
}
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (!phraseMatches) return@ActionButton
|
||||
|
||||
if (passwordDerived.isBlank()) {
|
||||
onSnackbar(errUnexpected)
|
||||
return@ActionButton
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
|
||||
try {
|
||||
deleteAccountWithDerivedPassword(passwordDerived)
|
||||
onDeleted()
|
||||
} catch (e: Exception) {
|
||||
onSnackbar(deleteErrorMessage(e, errUnexpected))
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = phraseMatches,
|
||||
loading = busy,
|
||||
destructive = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_delete_account_button))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package ru.fromchat.ui.main.settings.account.delete
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.auth_wrong_password
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.fill_all_fields
|
||||
import ru.fromchat.settings_delete_step_password_body
|
||||
import ru.fromchat.settings_delete_step_password_title
|
||||
import ru.fromchat.settings_next
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
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.trackImeScrollTarget
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun passwordVerifyStepPage(
|
||||
password: String,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onContinue: suspend (String) -> Unit,
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
||||
val wrongPassword = stringResource(Res.string.auth_wrong_password)
|
||||
|
||||
return ExpressiveStepPage(
|
||||
hero = ExpressiveHeroSpec(
|
||||
icon = Icons.Filled.Lock,
|
||||
polygon = MaterialShapes.Cookie6Sided.normalized(),
|
||||
containerColor = scheme.secondaryContainer,
|
||||
contentColor = scheme.onSecondaryContainer,
|
||||
),
|
||||
content = { imeScroll ->
|
||||
ExpressiveStepPageHeader(
|
||||
title = stringResource(Res.string.settings_delete_step_password_title),
|
||||
body = stringResource(Res.string.settings_delete_step_password_body),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text(stringResource(Res.string.settings_delete_step_password_title)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
enabled = !busy,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
colors = expressiveStepFieldColors(),
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
},
|
||||
button = {
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (password.isBlank()) {
|
||||
onSnackbar(fillAll)
|
||||
return@ActionButton
|
||||
}
|
||||
val username = ApiClient.user?.username.orEmpty()
|
||||
if (username.isBlank()) {
|
||||
onSnackbar(errUnexpected)
|
||||
return@ActionButton
|
||||
}
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching {
|
||||
val derived = deriveAuthSecret(username, password)
|
||||
verifyPasswordForDeletion(derived)
|
||||
busy = false
|
||||
onContinue(derived)
|
||||
}.onFailure { e ->
|
||||
onSnackbar(passwordVerifyErrorMessage(e, wrongPassword, errUnexpected))
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
},
|
||||
loading = busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(Res.string.settings_next))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+38
-110
@@ -5,9 +5,6 @@ package ru.fromchat.ui.main.settings.server
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,7 +21,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -53,9 +49,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -64,24 +58,23 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.SwitchListItem
|
||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||
import com.pr0gramm3r101.utils.toDp
|
||||
import com.pr0gramm3r101.utils.toPx
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
@@ -89,7 +82,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.getString
|
||||
@@ -130,48 +122,21 @@ import ru.fromchat.server_ip_hint
|
||||
import ru.fromchat.server_ip_label
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.CtaShape
|
||||
import ru.fromchat.ui.components.DisabledBringIntoViewSpec
|
||||
import ru.fromchat.ui.components.ExpressiveIconFrame
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.HazeActionButton
|
||||
import com.pr0gramm3r101.utils.LastAnchoredBottomArrangement
|
||||
import ru.fromchat.ui.components.LazyListFocusScrollEffect
|
||||
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
|
||||
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
|
||||
import ru.fromchat.ui.components.trackLazyListFocus
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import ru.fromchat.ui.main.settings.account.SettingsPasswordOutlineFieldShape
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
/**
|
||||
* Fixed [Dp] gaps between all children (resolved in [Density.arrange] like [Arrangement.spacedBy]),
|
||||
* plus extra height inserted **only** before the last child so it sits at the bottom when content
|
||||
* is shorter than the viewport. Uses a stable [remember] so [4.dp.roundToPx] changes from
|
||||
* recomposition do not swap the arrangement instance and nudge spacing by a pixel.
|
||||
*/
|
||||
@Stable
|
||||
private class ServerConfigSpacedByLastAnchoredBottom(
|
||||
private val space: Dp,
|
||||
) : Arrangement.Vertical {
|
||||
override val spacing get() = space
|
||||
|
||||
override fun Density.arrange(
|
||||
totalSize: Int,
|
||||
sizes: IntArray,
|
||||
outPositions: IntArray,
|
||||
) {
|
||||
val spacePx = space.roundToPx()
|
||||
when (sizes.size) {
|
||||
0 -> return
|
||||
1 -> {
|
||||
outPositions[0] = (totalSize - sizes[0]).coerceAtLeast(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var y = 0
|
||||
for (i in 0 until sizes.size - 1) {
|
||||
outPositions[i] = y
|
||||
y += sizes[i] + spacePx
|
||||
}
|
||||
|
||||
outPositions[sizes.size - 1] = y + (totalSize - sizes.sum() - (spacePx * (sizes.size - 1))).coerceAtLeast(0)
|
||||
}
|
||||
private object ServerConfigLazyListIndices {
|
||||
const val SERVER_IP_FIELD = 2
|
||||
const val PORT_FIELDS = 4
|
||||
}
|
||||
|
||||
private inline fun port(text: String, default: Int) = text
|
||||
@@ -183,32 +148,6 @@ private inline fun port(text: String, default: Int) = text
|
||||
private fun resolvedApiPort(apiPortText: String) = port(apiPortText, 443)
|
||||
private fun resolvedCallsPort(callsPortText: String) = port(callsPortText, DEFAULT_CALLS_PORT)
|
||||
|
||||
private suspend fun LazyListState.scrollFocusedItemIntoView(
|
||||
itemIndex: Int,
|
||||
viewportMarginPx: Float,
|
||||
) {
|
||||
if (layoutInfo.visibleItemsInfo.none { it.index == itemIndex }) {
|
||||
animateScrollToItem(itemIndex)
|
||||
}
|
||||
|
||||
val viewportStart = layoutInfo.viewportStartOffset + viewportMarginPx
|
||||
val viewportEnd = (layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding - viewportMarginPx)
|
||||
.coerceAtLeast(viewportStart)
|
||||
|
||||
val item = layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemIndex } ?: return
|
||||
|
||||
val itemStart = item.offset.toFloat()
|
||||
val itemEnd = itemStart + item.size
|
||||
|
||||
when {
|
||||
itemStart < viewportStart -> itemStart - viewportStart
|
||||
itemEnd > viewportEnd -> itemEnd - viewportEnd
|
||||
else -> 0f
|
||||
}.also { if (it != 0f) animateScrollBy(it) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@OptIn(
|
||||
ExperimentalFoundationApi::class,
|
||||
ExperimentalMaterial3Api::class,
|
||||
@@ -243,7 +182,7 @@ fun ServerConfigScreen() {
|
||||
var lastProbedConfig by remember { mutableStateOf<ServerConfigData?>(null) }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
val actionHazeState = rememberHazeState()
|
||||
var focusedItemIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val focusScrollState = rememberLazyListFocusScrollState()
|
||||
|
||||
|
||||
val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults)
|
||||
@@ -402,14 +341,14 @@ fun ServerConfigScreen() {
|
||||
bearer = ApiClient.token?.trim().orEmpty(),
|
||||
onNavigateLogin = {
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigateAndWipeBackStack("login")
|
||||
navController.navigateAndWipeBackStack("auth")
|
||||
}
|
||||
},
|
||||
onNavigateChat = {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!navController.popBackStack()) {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") {
|
||||
popUpTo("welcome") {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
@@ -421,7 +360,7 @@ fun ServerConfigScreen() {
|
||||
WebSocketManager.disconnect()
|
||||
runCatching { ApiClient.logout() }
|
||||
ApiClient.clearMemorySession()
|
||||
navController.navigateAndWipeBackStack("login")
|
||||
navController.navigateAndWipeBackStack("auth")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -465,36 +404,28 @@ fun ServerConfigScreen() {
|
||||
val floatingHeaderClearance = WindowInsets.statusBars.getTop(density).toDp(density) + 68.dp
|
||||
val bottomInsetPadding = innerPadding.calculateBottomPadding()
|
||||
val serverConfigListState = rememberLazyListState()
|
||||
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
LaunchedEffect(focusedItemIndex, bottomInsetPadding) {
|
||||
delay(40L)
|
||||
|
||||
serverConfigListState.scrollFocusedItemIntoView(
|
||||
itemIndex = focusedItemIndex ?: return@LaunchedEffect,
|
||||
viewportMarginPx = 12.dp.toPx(density),
|
||||
LazyListFocusScrollEffect(
|
||||
listState = serverConfigListState,
|
||||
focusState = focusScrollState,
|
||||
viewportBoundsInWindow = listViewportBounds,
|
||||
contentPaddingBottom = bottomInsetPadding,
|
||||
)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides remember {
|
||||
object : BringIntoViewSpec {
|
||||
override fun calculateScrollDistance(
|
||||
offset: Float,
|
||||
size: Float,
|
||||
containerSize: Float,
|
||||
): Float = 0f
|
||||
}
|
||||
}) {
|
||||
DisabledBringIntoViewSpec {
|
||||
LazyColumn(
|
||||
state = serverConfigListState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(actionHazeState),
|
||||
.hazeSource(actionHazeState)
|
||||
.onGloballyPositioned { listViewportBounds = it.boundsInWindow() },
|
||||
contentPadding = PaddingValues(bottom = bottomInsetPadding),
|
||||
verticalArrangement = remember {
|
||||
ServerConfigSpacedByLastAnchoredBottom(space = 4.dp)
|
||||
LastAnchoredBottomArrangement(space = 4.dp)
|
||||
},
|
||||
) {
|
||||
item { Spacer(Modifier.height(floatingHeaderClearance)) }
|
||||
@@ -560,11 +491,10 @@ fun ServerConfigScreen() {
|
||||
placeholder = { Text(stringResource(Res.string.server_ip_hint)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedItemIndex = 2
|
||||
}
|
||||
}
|
||||
.trackLazyListFocus(
|
||||
focusScrollState,
|
||||
ServerConfigLazyListIndices.SERVER_IP_FIELD,
|
||||
)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
singleLine = true,
|
||||
isError = !hostOk,
|
||||
@@ -604,11 +534,10 @@ fun ServerConfigScreen() {
|
||||
placeholder = { Text("8301") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedItemIndex = 4
|
||||
}
|
||||
},
|
||||
.trackLazyListFocus(
|
||||
focusScrollState,
|
||||
ServerConfigLazyListIndices.PORT_FIELDS,
|
||||
),
|
||||
singleLine = true,
|
||||
isError = apiPortError,
|
||||
supportingText = if (apiPortError) {
|
||||
@@ -638,11 +567,10 @@ fun ServerConfigScreen() {
|
||||
placeholder = { Text(DEFAULT_CALLS_PORT.toString()) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
focusedItemIndex = 4
|
||||
}
|
||||
},
|
||||
.trackLazyListFocus(
|
||||
focusScrollState,
|
||||
ServerConfigLazyListIndices.PORT_FIELDS,
|
||||
),
|
||||
singleLine = true,
|
||||
isError = callsPortError,
|
||||
supportingText = if (callsPortError) {{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imeAnimationSource
|
||||
import androidx.compose.foundation.layout.imeAnimationTarget
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
actual fun rememberImeMotion(): ImeMotion {
|
||||
val density = LocalDensity.current
|
||||
return ImeMotion(
|
||||
currentBottomPx = WindowInsets.ime.getBottom(density),
|
||||
sourceBottomPx = WindowInsets.imeAnimationSource.getBottom(density),
|
||||
targetBottomPx = WindowInsets.imeAnimationTarget.getBottom(density),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* IME inset motion for the current frame.
|
||||
*
|
||||
* On Android, [sourceBottomPx] / [targetBottomPx] come from
|
||||
* [androidx.compose.foundation.layout.WindowInsets.imeAnimationSource] and
|
||||
* [androidx.compose.foundation.layout.WindowInsets.imeAnimationTarget].
|
||||
*/
|
||||
@Immutable
|
||||
data class ImeMotion(
|
||||
val currentBottomPx: Int,
|
||||
val sourceBottomPx: Int,
|
||||
val targetBottomPx: Int,
|
||||
) {
|
||||
val isAnimating: Boolean
|
||||
get() = sourceBottomPx != targetBottomPx
|
||||
|
||||
val isOpeningAnimation: Boolean
|
||||
get() = isAnimating && targetBottomPx > sourceBottomPx
|
||||
|
||||
val isClosingAnimation: Boolean
|
||||
get() = isAnimating && targetBottomPx < sourceBottomPx
|
||||
|
||||
/** Open animation that started from a fully hidden keyboard. */
|
||||
val isOpeningFromClosed: Boolean
|
||||
get() = isOpeningAnimation && sourceBottomPx == 0
|
||||
}
|
||||
|
||||
/** Keyboard phase used to decide when bring-into-view may run. */
|
||||
enum class ImeKeyboardPhase {
|
||||
/** IME inset is zero. */
|
||||
Hidden,
|
||||
/** Keyboard animating up from fully hidden — follow with bring-into-view. */
|
||||
OpeningFromHidden,
|
||||
/** Keyboard visible and not in an inset animation. */
|
||||
Open,
|
||||
/** Keyboard animating down (inset shrinking). */
|
||||
Closing,
|
||||
/** Keyboard animating back up after a cancelled / partial dismiss. */
|
||||
ReopeningPartial,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param settledImeBottomPx last IME bottom when no inset animation was in progress.
|
||||
* @param previousReportedImeBottomPx IME bottom on the prior processed frame.
|
||||
*/
|
||||
fun ImeMotion.keyboardPhase(
|
||||
settledImeBottomPx: Int,
|
||||
previousReportedImeBottomPx: Int,
|
||||
): ImeKeyboardPhase {
|
||||
when {
|
||||
isOpeningFromClosed -> return ImeKeyboardPhase.OpeningFromHidden
|
||||
|
||||
isOpeningAnimation && sourceBottomPx > 0 && settledImeBottomPx > 0 ->
|
||||
return ImeKeyboardPhase.ReopeningPartial
|
||||
|
||||
isClosingAnimation -> return ImeKeyboardPhase.Closing
|
||||
|
||||
// iOS / fallback: inset growing from zero without animation metadata.
|
||||
previousReportedImeBottomPx <= 0 && currentBottomPx > 0 &&
|
||||
(previousReportedImeBottomPx < 0 || currentBottomPx > previousReportedImeBottomPx) ->
|
||||
return ImeKeyboardPhase.OpeningFromHidden
|
||||
|
||||
// iOS / fallback: inset shrinking frame-by-frame.
|
||||
previousReportedImeBottomPx > 0 && currentBottomPx in 1..<previousReportedImeBottomPx ->
|
||||
return ImeKeyboardPhase.Closing
|
||||
|
||||
currentBottomPx > 0 -> return ImeKeyboardPhase.Open
|
||||
|
||||
else -> return ImeKeyboardPhase.Hidden
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun rememberImeMotion(): ImeMotion
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
/**
|
||||
* Fixed [Dp] gaps between children, plus extra height before the last child so it sits at the
|
||||
* bottom when content is shorter than the viewport.
|
||||
*/
|
||||
@Stable
|
||||
class LastAnchoredBottomArrangement(
|
||||
private val space: Dp,
|
||||
) : Arrangement.Vertical {
|
||||
override val spacing get() = space
|
||||
|
||||
override fun Density.arrange(
|
||||
totalSize: Int,
|
||||
sizes: IntArray,
|
||||
outPositions: IntArray,
|
||||
) {
|
||||
val spacePx = space.roundToPx()
|
||||
when (sizes.size) {
|
||||
0 -> return
|
||||
1 -> {
|
||||
outPositions[0] = (totalSize - sizes[0]).coerceAtLeast(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var y = 0
|
||||
for (i in 0 until sizes.size - 1) {
|
||||
outPositions[i] = y
|
||||
y += sizes[i] + spacePx
|
||||
}
|
||||
|
||||
outPositions[sizes.size - 1] =
|
||||
y + (totalSize - sizes.sum() - (spacePx * (sizes.size - 1))).coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@Composable
|
||||
actual fun rememberImeMotion(): ImeMotion {
|
||||
val density = LocalDensity.current
|
||||
val currentBottomPx = WindowInsets.ime.getBottom(density)
|
||||
return ImeMotion(
|
||||
currentBottomPx = currentBottomPx,
|
||||
sourceBottomPx = currentBottomPx,
|
||||
targetBottomPx = currentBottomPx,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user