diff --git a/.cursor/rules/kotlin-clean-code.mdc b/.cursor/rules/kotlin-clean-code.mdc
new file mode 100644
index 0000000..0cbb131
--- /dev/null
+++ b/.cursor/rules/kotlin-clean-code.mdc
@@ -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`).
diff --git a/.cursor/rules/no-rg.mdc b/.cursor/rules/no-rg.mdc
new file mode 100644
index 0000000..1745ed8
--- /dev/null
+++ b/.cursor/rules/no-rg.mdc
@@ -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
+```
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 4d8ef04..81daac8 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -25,6 +25,24 @@
Имя пользователя — от 3 до 20 символов
Пароль — от 5 до 50 символов
Пароли не совпадают
+ Добро пожаловать в FromChat
+ 100% бесплатный мессенджер с открытым исходным кодом.
+ Начать
+ Не удалось подключиться к серверу
+ Неверный пароль
+ Слишком много попыток. Попробуйте позже.
+ Показать пароль
+ Скрыть пароль
+ %1$d/%2$d
+ Введите имя пользователя
+ Это ваш логин. С помощью него мы сможем отличить вас от других людей.
+ Создайте ваш профиль
+ Введите ваше отображаемое имя и, если хотите, несколько слов о себе.
+ Это имя пользователя уже занято. Выберите другое.
+ Введите пароль
+ Мы войдём в аккаунт или создадим новый.
+ Подтвердите пароль
+ Введите тот же пароль ещё раз.
Чаты
Контакты
Профиль
@@ -267,12 +285,29 @@
Версия, ссылки и другое
Аккаунт
+ Выйти?
+ Придётся войти снова.
Удалить аккаунт
Безвозвратно удалить аккаунт и данные
Удалить аккаунт?
Это нельзя отменить.
Аккаунт удалён
+ Удалить аккаунт?
+ Ваш аккаунт будет удалён навсегда. Если вы подтвердите действие, произойдёт следующее:
+ Вы потеряете всю историю переписки.
+ Ваше имя пользователя станет доступным всем, и его сможет занять любой.
+ Отправленные вами сообщения будут анонимизированы.
+ Это действие нельзя отменить.
+ Подтвердите пароль
+ Введите пароль, чтобы продолжить.
+ Последнее предупреждение
+ Ваш аккаунт будет удалён НАВСЕГДА и его НЕВОЗМОЖНО восстановить. Вы выйдете на всех устройствах и сразу потеряете доступ.
+ Удалить аккаунт
+ я подтверждаю, что хочу удалить свой аккаунт
+ Я подтверждаю, что хочу удалить свой аккаунт
+ Чтобы вы случайно не нажали кнопку, введите «%1$s» (без учёта регистра).
+
Что-то пошло не так
Неверное имя пользователя или пароль
Не удалось подключиться. Проверьте интернет.
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index a0408d1..71552f1 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -32,7 +32,24 @@
Username must be 3 to 20 characters
Password must be 5 to 50 characters
The two passwords don’t match
-
+ Welcome to FromChat
+ The 100% free and open source messenger.
+ Get started
+ Failed to connect to the server
+ Wrong password
+ Too many attempts. Try again later.
+ Show password
+ Hide password
+ %1$d/%2$d
+ Enter your username
+ This is your login. It helps us tell you apart from everyone else.
+ Create your profile
+ Enter your display name and, if you like, a few words about yourself.
+ This username was just taken. Please choose another one.
+ Enter your password
+ We will sign you in or create a new account.
+ Confirm your password
+ Enter the same password again.
Chats
Contacts
@@ -294,12 +311,29 @@
Version, links, and more
Account
+ Log out?
+ You will need to sign in again.
Delete account
Permanently delete your account and data
Delete account?
This cannot be undone.
Account deleted
+ Do you want to delete your account?
+ Your account will be deleted forever. If you confirm the action, this will happen:
+ You will lose all your chat history.
+ Your username will become available to everyone and anyone can claim it.
+ Your sent messages will be anonymized.
+ This cannot be undone.
+ Confirm your password
+ Enter your password to continue.
+ Last warning
+ Your account will be deleted FOREVER and CANNOT be recovered. You will be signed out on all devices and lose access immediately.
+ Delete account
+ i confirm that i want to delete my account
+ I confirm that I want to delete my account
+ To avoid you accidentally clicking the button, please type “%1$s” (case-insensitive).
+
Something went wrong
Wrong username or password
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 9577767..107f42e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -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().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()
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt
index b3122ec..323a412 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Utils.kt
@@ -16,7 +16,7 @@ suspend inline fun 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().detail
} else {
unexpectedError
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/DeleteAccountRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/DeleteAccountRequest.kt
new file mode 100644
index 0000000..67c9fd4
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/DeleteAccountRequest.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.api.schema.user
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DeleteAccountRequest(
+ val passwordDerived: String,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/VerifyPasswordRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/VerifyPasswordRequest.kt
new file mode 100644
index 0000000..d948cdf
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/VerifyPasswordRequest.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.api.schema.user
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class VerifyPasswordRequest(
+ val passwordDerived: String,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/CheckUsernameResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/CheckUsernameResponse.kt
new file mode 100644
index 0000000..9190edd
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/CheckUsernameResponse.kt
@@ -0,0 +1,8 @@
+package ru.fromchat.api.schema.user.auth
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CheckUsernameResponse(
+ val exists: Boolean,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt
index 08b8367..ca4875d 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/RegisterRequest.kt
@@ -7,5 +7,6 @@ data class RegisterRequest(
val username: String,
val display_name: String,
val password: String,
- val confirm_password: String
-)
\ No newline at end of file
+ val confirm_password: String,
+ val bio: String? = null,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index 895803f..216ff3e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -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)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt
new file mode 100644
index 0000000..0de5ebb
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/WelcomeScreen.kt
@@ -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))
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt
new file mode 100644
index 0000000..10f14dd
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt
@@ -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().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().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,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
deleted file mode 100644
index 7218af0..0000000
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/LoginScreen.kt
+++ /dev/null
@@ -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(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))
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt
new file mode 100644
index 0000000..0f1b9f0
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
deleted file mode 100644
index ead0997..0000000
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/RegisterScreen.kt
+++ /dev/null
@@ -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(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))
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt
new file mode 100644
index 0000000..9d6ed9f
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt
new file mode 100644
index 0000000..4a6479d
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ConfirmPasswordStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt
new file mode 100644
index 0000000..6c10e90
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt
index e13e09d..9089541 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt
@@ -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()
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt
index 6a4e987..5a3f7a1 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ActionButton.kt
@@ -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()
}
}
}
}
}
-}
\ No newline at end of file
+}
+
+@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()
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt
new file mode 100644
index 0000000..8218cc1
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt
@@ -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(null)
+ internal set
+ var secondaryCtaBoundsInWindow by mutableStateOf(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(null)
+ internal var predictiveToPage by mutableStateOf(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,
+ 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,
+ 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(null) }
+ var listViewportBoundsForSnackbar by remember { mutableStateOf(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(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,
+ )
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt
index 42dac84..b41f9f5 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt
@@ -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,
+ )
+ }
}
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt
new file mode 100644
index 0000000..3d04d89
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/LazyListFocusScroll.kt
@@ -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(null)
+ internal set
+ var focusedBoundsInWindow by mutableStateOf(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(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,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt
index 38e5e89..c078100 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SnackbarHost.kt
@@ -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,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt
index 63a3c21..e4109ce 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt
@@ -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"
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
index 39a9fda..bcbfea7 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
@@ -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))
}
- }
+ },
)
}
-}
\ No newline at end of file
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/ChangePasswordScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/ChangePasswordScreen.kt
deleted file mode 100644
index 24eb789..0000000
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/ChangePasswordScreen.kt
+++ /dev/null
@@ -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> {
- 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(null) }
- var predictiveToStep by remember { mutableStateOf(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
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/ChangePasswordScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/ChangePasswordScreen.kt
new file mode 100644
index 0000000..a5a4de9
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/ChangePasswordScreen.kt
@@ -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,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/ConfirmPasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/ConfirmPasswordStep.kt
new file mode 100644
index 0000000..5e3dbfc
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/ConfirmPasswordStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/CurrentPasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/CurrentPasswordStep.kt
new file mode 100644
index 0000000..dccd641
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/CurrentPasswordStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/NewPasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/NewPasswordStep.kt
new file mode 100644
index 0000000..0956a1c
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changepassword/NewPasswordStep.kt
@@ -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)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/ConsequencesStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/ConsequencesStep.kt
new file mode 100644
index 0000000..203b594
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/ConsequencesStep.kt
@@ -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))
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/DeleteAccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/DeleteAccountScreen.kt
new file mode 100644
index 0000000..8a471ec
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/DeleteAccountScreen.kt
@@ -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,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/FinalWarningStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/FinalWarningStep.kt
new file mode 100644
index 0000000..4762255
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/FinalWarningStep.kt
@@ -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))
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/PasswordVerifyStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/PasswordVerifyStep.kt
new file mode 100644
index 0000000..e782e49
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/delete/PasswordVerifyStep.kt
@@ -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))
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
index 663da01..d229929 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt
@@ -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(null) }
var showResetDialog by remember { mutableStateOf(false) }
val actionHazeState = rememberHazeState()
- var focusedItemIndex by remember { mutableStateOf(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(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) {{
diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.android.kt
new file mode 100644
index 0000000..2cdf8ee
--- /dev/null
+++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.android.kt
@@ -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),
+ )
+}
diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.kt
new file mode 100644
index 0000000..34afc5a
--- /dev/null
+++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.kt
@@ -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..
+ return ImeKeyboardPhase.Closing
+
+ currentBottomPx > 0 -> return ImeKeyboardPhase.Open
+
+ else -> return ImeKeyboardPhase.Hidden
+ }
+}
+
+@Composable
+expect fun rememberImeMotion(): ImeMotion
diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/LastAnchoredBottomArrangement.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/LastAnchoredBottomArrangement.kt
new file mode 100644
index 0000000..5c4e20e
--- /dev/null
+++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/LastAnchoredBottomArrangement.kt
@@ -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)
+ }
+}
diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.ios.kt
new file mode 100644
index 0000000..c1240c5
--- /dev/null
+++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/ImeMotion.ios.kt
@@ -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,
+ )
+}