Implement new account deletion and auth flows

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