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="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
||||||
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
||||||
<string name="passwords_dont_match">Пароли не совпадают</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="chats">Чаты</string>
|
||||||
<string name="contacts">Контакты</string>
|
<string name="contacts">Контакты</string>
|
||||||
<string name="profile">Профиль</string>
|
<string name="profile">Профиль</string>
|
||||||
@@ -267,12 +285,29 @@
|
|||||||
<string name="settings_hub_about_sub">Версия, ссылки и другое</string>
|
<string name="settings_hub_about_sub">Версия, ссылки и другое</string>
|
||||||
|
|
||||||
<string name="settings_account_title">Аккаунт</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">Удалить аккаунт</string>
|
||||||
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
|
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
|
||||||
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
||||||
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
||||||
<string name="settings_account_deleted">Аккаунт удалён</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_unexpected">Что-то пошло не так</string>
|
||||||
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
|
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
|
||||||
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</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="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="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="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 -->
|
<!-- Main Screen -->
|
||||||
<string name="chats">Chats</string>
|
<string name="chats">Chats</string>
|
||||||
<string name="contacts">Contacts</string>
|
<string name="contacts">Contacts</string>
|
||||||
@@ -294,12 +311,29 @@
|
|||||||
<string name="settings_hub_about_sub">Version, links, and more</string>
|
<string name="settings_hub_about_sub">Version, links, and more</string>
|
||||||
|
|
||||||
<string name="settings_account_title">Account</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">Delete account</string>
|
||||||
<string name="settings_account_delete_d">Permanently delete your account and data</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_title">Delete account?</string>
|
||||||
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
||||||
<string name="settings_account_deleted">Account deleted</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 -->
|
<!-- Error Messages -->
|
||||||
<string name="error_unexpected">Something went wrong</string>
|
<string name="error_unexpected">Something went wrong</string>
|
||||||
<string name="error_invalid_credentials">Wrong username or password</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.client.statement.HttpResponse
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.http.encodedPath
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.TransportCiphertext
|
||||||
import ru.fromchat.api.crypto.transport.TransportCrypto
|
import ru.fromchat.api.crypto.transport.TransportCrypto
|
||||||
import ru.fromchat.api.instance.InstanceIdGuard
|
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.WebSocketManager
|
||||||
import ru.fromchat.api.local.cache.readOutboundFileBytes
|
import ru.fromchat.api.local.cache.readOutboundFileBytes
|
||||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
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.ServerInstanceIdResponse
|
||||||
import ru.fromchat.api.schema.server.TransportKeyResponse
|
import ru.fromchat.api.schema.server.TransportKeyResponse
|
||||||
import ru.fromchat.api.schema.user.ChangePasswordApiRequest
|
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.FcmTokenRequest
|
||||||
import ru.fromchat.api.schema.user.User
|
import ru.fromchat.api.schema.user.User
|
||||||
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
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.LoginRequest
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
||||||
@@ -115,6 +123,8 @@ object ApiClient {
|
|||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Session & suspension ---
|
||||||
|
|
||||||
data class SuspensionState(
|
data class SuspensionState(
|
||||||
val isSuspended: Boolean = false,
|
val isSuspended: Boolean = false,
|
||||||
val reason: String? = null,
|
val reason: String? = null,
|
||||||
@@ -164,6 +174,8 @@ object ApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- HTTP clients ---
|
||||||
|
|
||||||
val http = createPlatformHttpClient {
|
val http = createPlatformHttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(json)
|
json(json)
|
||||||
@@ -209,6 +221,9 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.status.value == 401) {
|
if (response.status.value == 401) {
|
||||||
|
val path = response.call.request.url.encodedPath
|
||||||
|
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
|
||||||
|
if (!isCredentialCheck) {
|
||||||
token = null
|
token = null
|
||||||
user = null
|
user = null
|
||||||
clearSuspensionState()
|
clearSuspensionState()
|
||||||
@@ -218,6 +233,7 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (response.status.value == 403) {
|
if (response.status.value == 403) {
|
||||||
handleForbiddenAsPotentialSuspension(response)
|
handleForbiddenAsPotentialSuspension(response)
|
||||||
}
|
}
|
||||||
@@ -269,6 +285,8 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Server probe ---
|
||||||
|
|
||||||
suspend fun fetchServerInstanceId(apiBaseUrl: String): String {
|
suspend fun fetchServerInstanceId(apiBaseUrl: String): String {
|
||||||
val base = apiBaseUrl.trimEnd('/')
|
val base = apiBaseUrl.trimEnd('/')
|
||||||
return httpProbe.get("$base/instance_id").body<ServerInstanceIdResponse>().instanceId.trim()
|
return httpProbe.get("$base/instance_id").body<ServerInstanceIdResponse>().instanceId.trim()
|
||||||
@@ -290,11 +308,30 @@ object ApiClient {
|
|||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
|
||||||
suspend fun refreshServerInstanceFingerprint() {
|
suspend fun refreshServerInstanceFingerprint() {
|
||||||
|
if (token.isNullOrEmpty()) return
|
||||||
|
val config = Settings.serverConfig
|
||||||
runCatching {
|
runCatching {
|
||||||
val id = fetchServerInstanceId(ServerConfig.apiBaseUrl)
|
when (
|
||||||
if (id.isNotEmpty()) {
|
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
|
Settings.lastKnownServerInstanceId = id
|
||||||
InstanceRegistryStore.registerInstanceEncountered(id)
|
CacheContext.setActiveInstance(id, user?.id)
|
||||||
|
scheduleOutboxProcessing(id)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,12 +372,10 @@ object ApiClient {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var user: User? = null
|
var user: User? = null
|
||||||
|
|
||||||
// Global auth error handler
|
|
||||||
var onAuthError: (() -> Unit)? = null
|
var onAuthError: (() -> Unit)? = null
|
||||||
|
|
||||||
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? {
|
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? =
|
||||||
return response.headers["suspension_reason"]?.trim()?.takeIf { it.isNotBlank() }
|
response.headers["suspension_reason"]?.trim()?.takeIf { it.isNotBlank() }
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleForbiddenAsPotentialSuspension(response: HttpResponse) {
|
private fun handleForbiddenAsPotentialSuspension(response: HttpResponse) {
|
||||||
if (response.status.value != 403) return
|
if (response.status.value != 403) return
|
||||||
@@ -349,7 +384,8 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load persisted token and user info
|
// --- Auth ---
|
||||||
|
|
||||||
suspend fun loadPersistedData() {
|
suspend fun loadPersistedData() {
|
||||||
try {
|
try {
|
||||||
val savedToken = secureSettings.getString("auth_token", "")
|
val savedToken = secureSettings.getString("auth_token", "")
|
||||||
@@ -383,6 +419,15 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.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.
|
* 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.
|
* 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) =
|
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||||
http
|
http
|
||||||
.get("${ServerConfig.apiBaseUrl}/get_messages") {
|
.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.
|
* 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 {
|
try {
|
||||||
return http
|
return http
|
||||||
.post("${ServerConfig.apiBaseUrl}/account/delete") {
|
.post("${ServerConfig.apiBaseUrl}/account/delete") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(body)
|
||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
@@ -1196,6 +1252,7 @@ object ApiClient {
|
|||||||
return http
|
return http
|
||||||
.post("${ServerConfig.apiBaseUrl}/delete") {
|
.post("${ServerConfig.apiBaseUrl}/delete") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(body)
|
||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ suspend inline fun <Response> apiRequest(
|
|||||||
onSuccess(response)
|
onSuccess(response)
|
||||||
return Result.success(response)
|
return Result.success(response)
|
||||||
} catch (e: ClientRequestException) {
|
} 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
|
e.response.body<ErrorResponse>().detail
|
||||||
} else {
|
} else {
|
||||||
unexpectedError
|
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 username: String,
|
||||||
val display_name: String,
|
val display_name: String,
|
||||||
val password: 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.compose.setSingletonImageLoaderFactory
|
||||||
import coil3.svg.SvgDecoder
|
import coil3.svg.SvgDecoder
|
||||||
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
|
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
|
||||||
|
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||||
import com.pr0gramm3r101.utils.rememberSystemBarsController
|
import com.pr0gramm3r101.utils.rememberSystemBarsController
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.MainScope
|
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.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import ru.fromchat.ui.auth.LoginScreen
|
import ru.fromchat.ui.auth.AuthScreen
|
||||||
import ru.fromchat.ui.auth.RegisterScreen
|
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
||||||
import ru.fromchat.ui.chat.panels.dm.DmNav
|
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.NotificationsScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
import ru.fromchat.ui.main.settings.account.AccountScreen
|
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.main.settings.server.ServerConfigScreen
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
@@ -205,7 +206,7 @@ fun App(
|
|||||||
hasToken && startAtDmConversationUserId != null -> "chat"
|
hasToken && startAtDmConversationUserId != null -> "chat"
|
||||||
hasToken && startAtPublicChat -> "chats/publicChat"
|
hasToken && startAtPublicChat -> "chats/publicChat"
|
||||||
hasToken && !startAtPublicChat -> "chat"
|
hasToken && !startAtPublicChat -> "chat"
|
||||||
else -> "login"
|
else -> "welcome"
|
||||||
}
|
}
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -233,7 +234,7 @@ fun App(
|
|||||||
LaunchedEffect(sessionLogoutRequired) {
|
LaunchedEffect(sessionLogoutRequired) {
|
||||||
if (!sessionLogoutRequired) return@LaunchedEffect
|
if (!sessionLogoutRequired) return@LaunchedEffect
|
||||||
logoutIfInstanceUnsupported()
|
logoutIfInstanceUnsupported()
|
||||||
startDestination = "login"
|
startDestination = "welcome"
|
||||||
sessionLogoutRequired = false
|
sessionLogoutRequired = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +314,7 @@ fun App(
|
|||||||
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
|
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
|
||||||
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
|
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
|
||||||
)
|
)
|
||||||
if (startDestination == null || startDestination == "login") {
|
if (startDestination == null || startDestination == "welcome") {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,25 +393,28 @@ fun App(
|
|||||||
ServerConfigScreen()
|
ServerConfigScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("login") {
|
composable("welcome") {
|
||||||
LoginScreen(
|
WelcomeScreen(
|
||||||
onLoginSuccess = {
|
onGetStarted = {
|
||||||
WebSocketManager.connect(forceRestart = true)
|
navController.navigate("auth") {
|
||||||
navController.navigate("chat") {
|
popUpTo("auth") { inclusive = true }
|
||||||
popUpTo("login") { inclusive = true }
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNavigateToRegister = { navController.navigate("register") }
|
onAlreadyLoggedIn = {
|
||||||
|
WebSocketManager.connect(forceRestart = true)
|
||||||
|
navController.navigateAndWipeBackStack("chat")
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("register") {
|
composable("auth") {
|
||||||
RegisterScreen(
|
AuthScreen(
|
||||||
onRegistered = {
|
onAuthSuccess = {
|
||||||
navController.navigate("chat") {
|
WebSocketManager.connect(forceRestart = true)
|
||||||
popUpTo("login") { inclusive = true }
|
navController.navigateAndWipeBackStack("chat")
|
||||||
}
|
},
|
||||||
}
|
onBackToWelcome = { navController.navigateUp() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,11 +603,20 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
|
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
|
||||||
SettingsSecurityPasswordFlowScreen(
|
ChangePasswordScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
onDonePopToHub = {
|
onDone = { navController.popBackStack() },
|
||||||
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(
|
AccountScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
navController.navigate("login") {
|
navController.navigate("welcome") {
|
||||||
popUpTo("chat") { inclusive = true }
|
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 = {
|
ApiClient.onAuthError = {
|
||||||
Logger.d("App", "Global auth error handler triggered, navigating to login")
|
Logger.d("App", "Global auth error handler triggered, navigating to login")
|
||||||
runCatching {
|
runCatching {
|
||||||
navController.navigate("login") {
|
navController.navigateAndWipeBackStack("welcome")
|
||||||
popUpTo("chat") { inclusive = true }
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Logger.w("App", "Auth navigation failed: ${e.message}", 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()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(panel) {
|
LaunchedEffect(panel, activeInstanceId) {
|
||||||
|
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||||
if (panel.getState().messages.isEmpty()) {
|
if (panel.getState().messages.isEmpty()) {
|
||||||
panel.loadMessages()
|
panel.loadMessages()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
package ru.fromchat.ui.components
|
package ru.fromchat.ui.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@@ -34,27 +36,38 @@ fun ActionButton(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
loading: Boolean = false,
|
loading: Boolean = false,
|
||||||
|
outlined: Boolean = false,
|
||||||
|
destructive: Boolean = false,
|
||||||
|
leadingIcon: ImageVector? = null,
|
||||||
interactionSource: MutableInteractionSource? = null,
|
interactionSource: MutableInteractionSource? = null,
|
||||||
content: @Composable (RowScope.() -> Unit)
|
content: @Composable (RowScope.() -> Unit)
|
||||||
) {
|
) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
val showCtaAsPrimary = enabled || loading
|
val showCtaAsPrimary = enabled || loading
|
||||||
val ctaTargetContainer =
|
|
||||||
if (showCtaAsPrimary)
|
val containerTarget = when {
|
||||||
MaterialTheme.colorScheme.primary
|
outlined -> scheme.surface
|
||||||
else MaterialTheme.colorScheme.surfaceContainerHigh
|
showCtaAsPrimary && destructive -> scheme.error
|
||||||
val ctaTargetContent =
|
showCtaAsPrimary -> scheme.primary
|
||||||
if (showCtaAsPrimary)
|
else -> scheme.surfaceContainerHigh
|
||||||
MaterialTheme.colorScheme.onPrimary
|
}
|
||||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
val contentTarget = when {
|
||||||
val ctaContainer by animateColorAsState(
|
outlined && showCtaAsPrimary -> scheme.primary
|
||||||
ctaTargetContainer,
|
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),
|
animationSpec = tween(durationMillis = 220),
|
||||||
label = "serverConfigCtaContainer",
|
label = "actionButtonContainer",
|
||||||
)
|
)
|
||||||
val ctaContent by animateColorAsState(
|
val contentColor by animateColorAsState(
|
||||||
ctaTargetContent,
|
targetValue = contentTarget,
|
||||||
animationSpec = tween(durationMillis = 220),
|
animationSpec = tween(durationMillis = 220),
|
||||||
label = "serverConfigCtaContent",
|
label = "actionButtonContent",
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -70,11 +83,12 @@ fun ActionButton(
|
|||||||
.then(modifier),
|
.then(modifier),
|
||||||
shape = CtaShape,
|
shape = CtaShape,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = ctaContainer,
|
containerColor = containerColor,
|
||||||
contentColor = ctaContent,
|
contentColor = contentColor,
|
||||||
disabledContainerColor = ctaContainer,
|
disabledContainerColor = containerColor,
|
||||||
disabledContentColor = ctaContent,
|
disabledContentColor = contentColor,
|
||||||
),
|
),
|
||||||
|
border = if (outlined) BorderStroke(1.dp, scheme.outline) else null,
|
||||||
elevation = ButtonDefaults.buttonElevation(
|
elevation = ButtonDefaults.buttonElevation(
|
||||||
defaultElevation = 0.dp,
|
defaultElevation = 0.dp,
|
||||||
pressedElevation = 0.dp,
|
pressedElevation = 0.dp,
|
||||||
@@ -90,24 +104,27 @@ fun ActionButton(
|
|||||||
.defaultMinSize(minHeight = 24.dp),
|
.defaultMinSize(minHeight = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
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) {
|
if (loading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = ctaContent,
|
color = contentColor,
|
||||||
)
|
)
|
||||||
} else {
|
} 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(
|
CompositionLocalProvider(
|
||||||
LocalTextAlign provides TextAlign.Center
|
LocalTextAlign provides TextAlign.Center,
|
||||||
) {
|
) {
|
||||||
content()
|
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)
|
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HazeActionButton(
|
fun HazeBottomBar(
|
||||||
onClick: () -> Unit,
|
|
||||||
hazeState: HazeState,
|
hazeState: HazeState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
innerModifier: Modifier = Modifier,
|
content: @Composable () -> Unit,
|
||||||
enabled: Boolean = true,
|
|
||||||
loading: Boolean = false,
|
|
||||||
interactionSource: MutableInteractionSource? = null,
|
|
||||||
content: @Composable (RowScope.() -> Unit)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -50,14 +45,31 @@ fun HazeActionButton(
|
|||||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||||
.padding(top = 0.dp, bottom = 16.dp),
|
.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(
|
ActionButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = innerModifier,
|
modifier = innerModifier,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
loading = loading,
|
loading = loading,
|
||||||
interactionSource = interactionSource,
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Snackbar
|
import androidx.compose.material3.Snackbar
|
||||||
import androidx.compose.material3.SnackbarDefaults
|
import androidx.compose.material3.SnackbarDefaults
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Shape
|
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). */
|
/** Single destination: in-screen steps + morphing hero (no nested nav routes per step). */
|
||||||
const val SecurityPasswordFlow = "settings/security/password"
|
const val SecurityPasswordFlow = "settings/security/password"
|
||||||
const val Account = "settings/account"
|
const val Account = "settings/account"
|
||||||
|
const val AccountDeleteFlow = "settings/account/delete"
|
||||||
const val ServerConfig = "serverConfig"
|
const val ServerConfig = "serverConfig"
|
||||||
const val About = "about"
|
const val About = "about"
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-42
@@ -21,8 +21,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MediumTopAppBar
|
import androidx.compose.material3.MediumTopAppBar
|
||||||
import androidx.compose.material3.Scaffold
|
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.TextButton
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
@@ -44,30 +42,30 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.cancel
|
import ru.fromchat.cancel
|
||||||
import ru.fromchat.confirm
|
|
||||||
import ru.fromchat.error_unexpected
|
|
||||||
import ru.fromchat.logout
|
import ru.fromchat.logout
|
||||||
import ru.fromchat.settings_account_delete
|
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_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_account_title
|
||||||
import ru.fromchat.settings_change_password
|
import ru.fromchat.settings_change_password
|
||||||
import ru.fromchat.settings_security_change_password_sub
|
import ru.fromchat.settings_security_change_password_sub
|
||||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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 scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
var showLogoutConfirm by remember { mutableStateOf(false) }
|
||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
|
||||||
val errUnexpected = stringResource(Res.string.error_unexpected)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text(stringResource(Res.string.settings_account_title)) },
|
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(
|
ListItem(
|
||||||
headline = stringResource(Res.string.logout),
|
headline = stringResource(Res.string.logout),
|
||||||
onClick = {
|
onClick = { showLogoutConfirm = true },
|
||||||
scope.launch {
|
|
||||||
runCatching { ApiClient.logout() }
|
|
||||||
WebSocketManager.disconnect()
|
|
||||||
onLogout()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.Logout,
|
Icons.AutoMirrored.Filled.Logout,
|
||||||
@@ -121,48 +122,36 @@ fun AccountScreen(onBack: () -> Unit, onLogout: () -> Unit, onChangePassword: ()
|
|||||||
Modifier.size(24.dp),
|
Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = { showDeleteConfirm = false },
|
onDismissRequest = { showLogoutConfirm = false },
|
||||||
title = { Text(stringResource(Res.string.settings_account_delete_confirm_title)) },
|
title = { Text(stringResource(Res.string.settings_account_logout_confirm_title)) },
|
||||||
text = { Text(stringResource(Res.string.settings_account_delete_confirm_body)) },
|
text = { Text(stringResource(Res.string.settings_account_logout_confirm_body)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showDeleteConfirm = false
|
showLogoutConfirm = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching { ApiClient.logout() }
|
||||||
ApiClient.deleteAccount()
|
|
||||||
WebSocketManager.disconnect()
|
WebSocketManager.disconnect()
|
||||||
ApiClient.clearLocalSession()
|
|
||||||
onLogout()
|
onLogout()
|
||||||
}.onFailure {
|
|
||||||
snackbarHostState.showSnackbar(it.message ?: errUnexpected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Text(stringResource(Res.string.confirm))
|
Text(stringResource(Res.string.logout))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showDeleteConfirm = false }) {
|
TextButton(onClick = { showLogoutConfirm = false }) {
|
||||||
Text(stringResource(Res.string.cancel))
|
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.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -53,9 +49,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -64,24 +58,23 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.layout.Layout
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Constraints
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
import com.pr0gramm3r101.components.SwitchListItem
|
import com.pr0gramm3r101.components.SwitchListItem
|
||||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||||
import com.pr0gramm3r101.utils.toDp
|
import com.pr0gramm3r101.utils.toDp
|
||||||
import com.pr0gramm3r101.utils.toPx
|
|
||||||
import dev.chrisbanes.haze.HazeProgressive
|
import dev.chrisbanes.haze.HazeProgressive
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
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.materials.HazeMaterials
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.getString
|
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.server_ip_label
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.components.CtaShape
|
import ru.fromchat.ui.components.CtaShape
|
||||||
|
import ru.fromchat.ui.components.DisabledBringIntoViewSpec
|
||||||
import ru.fromchat.ui.components.ExpressiveIconFrame
|
import ru.fromchat.ui.components.ExpressiveIconFrame
|
||||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||||
import ru.fromchat.ui.components.HazeActionButton
|
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.SettingsStepHorizontalPadding
|
||||||
import ru.fromchat.ui.main.settings.account.SettingsPasswordOutlineFieldShape
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private object ServerConfigLazyListIndices {
|
||||||
/**
|
const val SERVER_IP_FIELD = 2
|
||||||
* Fixed [Dp] gaps between all children (resolved in [Density.arrange] like [Arrangement.spacedBy]),
|
const val PORT_FIELDS = 4
|
||||||
* 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 inline fun port(text: String, default: Int) = text
|
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 resolvedApiPort(apiPortText: String) = port(apiPortText, 443)
|
||||||
private fun resolvedCallsPort(callsPortText: String) = port(callsPortText, DEFAULT_CALLS_PORT)
|
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(
|
@OptIn(
|
||||||
ExperimentalFoundationApi::class,
|
ExperimentalFoundationApi::class,
|
||||||
ExperimentalMaterial3Api::class,
|
ExperimentalMaterial3Api::class,
|
||||||
@@ -243,7 +182,7 @@ fun ServerConfigScreen() {
|
|||||||
var lastProbedConfig by remember { mutableStateOf<ServerConfigData?>(null) }
|
var lastProbedConfig by remember { mutableStateOf<ServerConfigData?>(null) }
|
||||||
var showResetDialog by remember { mutableStateOf(false) }
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
val actionHazeState = rememberHazeState()
|
val actionHazeState = rememberHazeState()
|
||||||
var focusedItemIndex by remember { mutableStateOf<Int?>(null) }
|
val focusScrollState = rememberLazyListFocusScrollState()
|
||||||
|
|
||||||
|
|
||||||
val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults)
|
val strSnackbarDefaults = stringResource(Res.string.server_config_snackbar_defaults)
|
||||||
@@ -402,14 +341,14 @@ fun ServerConfigScreen() {
|
|||||||
bearer = ApiClient.token?.trim().orEmpty(),
|
bearer = ApiClient.token?.trim().orEmpty(),
|
||||||
onNavigateLogin = {
|
onNavigateLogin = {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
navController.navigateAndWipeBackStack("login")
|
navController.navigateAndWipeBackStack("auth")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNavigateChat = {
|
onNavigateChat = {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (!navController.popBackStack()) {
|
if (!navController.popBackStack()) {
|
||||||
navController.navigate("chat") {
|
navController.navigate("chat") {
|
||||||
popUpTo("login") {
|
popUpTo("welcome") {
|
||||||
inclusive = true
|
inclusive = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,7 +360,7 @@ fun ServerConfigScreen() {
|
|||||||
WebSocketManager.disconnect()
|
WebSocketManager.disconnect()
|
||||||
runCatching { ApiClient.logout() }
|
runCatching { ApiClient.logout() }
|
||||||
ApiClient.clearMemorySession()
|
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 floatingHeaderClearance = WindowInsets.statusBars.getTop(density).toDp(density) + 68.dp
|
||||||
val bottomInsetPadding = innerPadding.calculateBottomPadding()
|
val bottomInsetPadding = innerPadding.calculateBottomPadding()
|
||||||
val serverConfigListState = rememberLazyListState()
|
val serverConfigListState = rememberLazyListState()
|
||||||
|
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(focusedItemIndex, bottomInsetPadding) {
|
LazyListFocusScrollEffect(
|
||||||
delay(40L)
|
listState = serverConfigListState,
|
||||||
|
focusState = focusScrollState,
|
||||||
serverConfigListState.scrollFocusedItemIntoView(
|
viewportBoundsInWindow = listViewportBounds,
|
||||||
itemIndex = focusedItemIndex ?: return@LaunchedEffect,
|
contentPaddingBottom = bottomInsetPadding,
|
||||||
viewportMarginPx = 12.dp.toPx(density),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
CompositionLocalProvider(LocalBringIntoViewSpec provides remember {
|
DisabledBringIntoViewSpec {
|
||||||
object : BringIntoViewSpec {
|
|
||||||
override fun calculateScrollDistance(
|
|
||||||
offset: Float,
|
|
||||||
size: Float,
|
|
||||||
containerSize: Float,
|
|
||||||
): Float = 0f
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = serverConfigListState,
|
state = serverConfigListState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.consumeWindowInsets(innerPadding)
|
.consumeWindowInsets(innerPadding)
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.hazeSource(actionHazeState),
|
.hazeSource(actionHazeState)
|
||||||
|
.onGloballyPositioned { listViewportBounds = it.boundsInWindow() },
|
||||||
contentPadding = PaddingValues(bottom = bottomInsetPadding),
|
contentPadding = PaddingValues(bottom = bottomInsetPadding),
|
||||||
verticalArrangement = remember {
|
verticalArrangement = remember {
|
||||||
ServerConfigSpacedByLastAnchoredBottom(space = 4.dp)
|
LastAnchoredBottomArrangement(space = 4.dp)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
item { Spacer(Modifier.height(floatingHeaderClearance)) }
|
item { Spacer(Modifier.height(floatingHeaderClearance)) }
|
||||||
@@ -560,11 +491,10 @@ fun ServerConfigScreen() {
|
|||||||
placeholder = { Text(stringResource(Res.string.server_ip_hint)) },
|
placeholder = { Text(stringResource(Res.string.server_ip_hint)) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged {
|
.trackLazyListFocus(
|
||||||
if (it.isFocused) {
|
focusScrollState,
|
||||||
focusedItemIndex = 2
|
ServerConfigLazyListIndices.SERVER_IP_FIELD,
|
||||||
}
|
)
|
||||||
}
|
|
||||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
isError = !hostOk,
|
isError = !hostOk,
|
||||||
@@ -604,11 +534,10 @@ fun ServerConfigScreen() {
|
|||||||
placeholder = { Text("8301") },
|
placeholder = { Text("8301") },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged {
|
.trackLazyListFocus(
|
||||||
if (it.isFocused) {
|
focusScrollState,
|
||||||
focusedItemIndex = 4
|
ServerConfigLazyListIndices.PORT_FIELDS,
|
||||||
}
|
),
|
||||||
},
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
isError = apiPortError,
|
isError = apiPortError,
|
||||||
supportingText = if (apiPortError) {
|
supportingText = if (apiPortError) {
|
||||||
@@ -638,11 +567,10 @@ fun ServerConfigScreen() {
|
|||||||
placeholder = { Text(DEFAULT_CALLS_PORT.toString()) },
|
placeholder = { Text(DEFAULT_CALLS_PORT.toString()) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.onFocusChanged {
|
.trackLazyListFocus(
|
||||||
if (it.isFocused) {
|
focusScrollState,
|
||||||
focusedItemIndex = 4
|
ServerConfigLazyListIndices.PORT_FIELDS,
|
||||||
}
|
),
|
||||||
},
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
isError = callsPortError,
|
isError = callsPortError,
|
||||||
supportingText = if (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