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, + ) +}