From 87934c74487e500dd1a3196b756032626bff5faa Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 7 Apr 2026 22:01:19 +0300 Subject: [PATCH] Implement new settings Signed-off-by: denis0001-dev --- .cursor/rules/android-build-deploy-device.mdc | 1 + .cursor/rules/debug-logging-ktor.mdc | 16 + .cursor/rules/general.mdc | 6 +- .cursor/rules/no-gradle-cache-grep.mdc | 20 +- .../ru/fromchat/debug/DebugLogger.android.kt | 55 + .../OpenAppNotificationSettings.android.kt | 26 + .../ui/ImeScrollWithKeyboard.android.kt | 8 + .../SettingsPredictiveBack.android.kt | 28 + .../composeResources/values-ru/strings.xml | 70 + .../composeResources/values/strings.xml | 70 + .../kotlin/ru/fromchat/api/ApiClient.kt | 76 +- .../kotlin/ru/fromchat/api/Models.kt | 35 + .../ru/fromchat/api/UpdateSyncManager.kt | 13 + .../kotlin/ru/fromchat/core/Settings.kt | 35 +- .../ru/fromchat/crypto/IdentityKeyManager.kt | 11 + .../kotlin/ru/fromchat/debug/DebugLogger.kt | 16 + .../platform/OpenAppNotificationSettings.kt | 7 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 89 +- .../ru/fromchat/ui/ImeScrollWithKeyboard.kt | 9 + .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 5 +- .../kotlin/ru/fromchat/ui/main/SettingsTab.kt | 250 --- .../ui/main/settings/SettingsExpressive.kt | 288 +++ .../ui/main/settings/SettingsNavHost.kt | 28 + .../main/settings/SettingsPredictiveBack.kt | 18 + .../ui/main/settings/SettingsRoutes.kt | 14 + .../ui/main/settings/SettingsScreens.kt | 1789 +++++++++++++++++ .../ru/fromchat/debug/DebugLogger.ios.kt | 8 + .../OpenAppNotificationSettings.ios.kt | 12 + .../fromchat/ui/ImeScrollWithKeyboard.ios.kt | 5 + .../settings/SettingsPredictiveBack.ios.kt | 15 + 30 files changed, 2752 insertions(+), 271 deletions(-) create mode 100644 .cursor/rules/debug-logging-ktor.mdc create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt delete mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt diff --git a/.cursor/rules/android-build-deploy-device.mdc b/.cursor/rules/android-build-deploy-device.mdc index b144e5d..b95b490 100644 --- a/.cursor/rules/android-build-deploy-device.mdc +++ b/.cursor/rules/android-build-deploy-device.mdc @@ -15,6 +15,7 @@ Whenever you change anything under **`app:android`**, **`app:shared`** (`commonM `app/android/build/outputs/apk/debug/android-debug.apk` in this repo. 6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`**. The debug APK from `assembleDebug` uses `applicationIdSuffix = ".beta"` (`app/android/build.gradle.kts`), so the on-device package is **`ru.fromchat.beta`**, not `ru.fromchat`—`ru.fromchat` will fail to launch after a debug install. (Release / no-suffix id is `ru.fromchat`.) +7. **Smoke-test after launch** (when a device is online): use **`mobile_list_elements_on_screen`** on the chosen device, then exercise the flows your change touched (e.g. open **Settings**, drill into **Appearance**, **Notifications**, **Devices**, **Security** steps, **Account**, **About**—tap through and use **Back**). Fix any crash or obvious broken UI before finishing. Also run **`:app:shared:compileKotlinIosArm64`** (and fix errors) when shared Kotlin changes should stay valid for iOS—either with the same Gradle invocation as in `general.mdc` or right after the Android APK build. diff --git a/.cursor/rules/debug-logging-ktor.mdc b/.cursor/rules/debug-logging-ktor.mdc new file mode 100644 index 0000000..1547ce4 --- /dev/null +++ b/.cursor/rules/debug-logging-ktor.mdc @@ -0,0 +1,16 @@ +--- +description: Use Ktor HttpClient for debug-mode logging instead of platform APIs +alwaysApply: true +--- + +# Debug-mode logging with Ktor + +- Prefer **Ktor `HttpClient`** for all debug/instrumentation HTTP logging in this project. +- **Do NOT** use `HttpURLConnection`, raw `OkHttpClient`, or other low-level networking APIs for debug-only logging. +- Centralize debug logging behind `ru.fromchat.debug.DebugLogger` and keep it **best-effort only** (never throw, never crash the app). +- When adding new debug logging: + - Use a shared `HttpClient` instance (per platform) configured with the appropriate engine (e.g. OkHttp on Android). + - Send JSON NDJSON-style payloads to the configured debug endpoint. + - Avoid blocking the main thread; run network I/O in coroutines on a background dispatcher. +- Keep debug logging code small and self-contained so it is easy to remove or disable when no longer needed. + diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 1532b51..175cdce 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -4,9 +4,10 @@ alwaysApply: true When working with the mobile app: +- **Always verify before finishing:** run the relevant Gradle compile/build for what you changed (at minimum `:app:shared:compileAndroidMain` and `:app:shared:compileKotlinIosArm64` when `commonMain` edits), fix all errors and warnings that indicate breakage, and do not hand off a change you have not compiled locally. - After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors. -- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected. For **`mobile_launch_app`** after a debug install, use package **`ru.fromchat.beta`** (not `ru.fromchat`); details in that rule. +- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected. For **`mobile_launch_app`** after a debug install, use package **`ru.fromchat.beta`** (not `ru.fromchat`); details in that rule. After launch, **smoke-test** the affected flows with **`mobile_list_elements_on_screen`** and navigation (see that rule step 7). # ULTIMATE SILENCE & EFFICIENCY POLICY - ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. @@ -56,6 +57,9 @@ When working with the mobile app: - `GlobalScope.launch { }` for fire-and-forget background operations on iOS - Platform-specific logging with `ru.fromchat.core.Logger` +## Compose Multiplatform / DrawScope +- In `commonMain`, `Canvas { }` does not always resolve `translate` / `scale` without explicit imports: `androidx.compose.ui.graphics.drawscope.translate` and `androidx.compose.ui.graphics.drawscope.scale`. Use `translate(left = …, top = …)` (not `dx`/`dy`). + ## Modifier.conditional - Use `Modifier.conditional` when you need to apply different modifiers based on a condition. - Both `if` and `else` closures are `@Composable` and receive the current Modifier; they return a Modifier to be appended. diff --git a/.cursor/rules/no-gradle-cache-grep.mdc b/.cursor/rules/no-gradle-cache-grep.mdc index de0fe94..09e0a93 100644 --- a/.cursor/rules/no-gradle-cache-grep.mdc +++ b/.cursor/rules/no-gradle-cache-grep.mdc @@ -1,10 +1,22 @@ --- -description: Never search Gradle dependency caches with grep or ripgrep +description: Never touch Gradle caches — no reads, writes, or searches there alwaysApply: true --- -# Gradle caches — no grep/ripgrep +# Gradle caches — never touch -Do **not** run `grep`, `ripgrep` (`rg`), or similar searches inside Gradle cache directories (e.g. `~/.gradle/caches`, `**/.gradle/caches`, or paths under the Gradle user home) to find framework or dependency source. +The agent must **never interact with Gradle cache directories** in any way. -Use instead: project source under the workspace, official docs, IDE navigation, or dependency coordinates declared in `gradle/libs.versions.toml` / `*.gradle.kts`. +## Forbidden (including “read-only”) + +- **Do not** read, open, list, traverse, copy, move, delete, or modify anything under Gradle cache paths (e.g. `~/.gradle/caches`, `**/.gradle/caches`, Gradle user home caches, transform outputs, jars that exist only under caches). +- **Do not** run `grep`, `ripgrep` (`rg`), `find`, `ls`, glob tools, or **any** search or directory listing **inside** those paths — even for “read-only” research. +- **Do not** cite cache paths as the source of truth for framework or dependency source code. + +## Use instead + +- Project source under the **workspace** +- Official documentation and dependency coordinates in **`gradle/libs.versions.toml`** / **`*.gradle.kts`** +- IDE navigation in the repo + +Normal Gradle builds on the developer machine may still use the daemon and local caches; this rule governs **agent** behavior only. diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt new file mode 100644 index 0000000..b180964 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/debug/DebugLogger.android.kt @@ -0,0 +1,55 @@ +package ru.fromchat.debug + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +actual object DebugLogger { + private const val ENDPOINT = + "http://127.0.0.1:7809/ingest/d9aebf8d-fb01-41c9-af88-eb0676507989" + private const val SESSION_ID = "9be525" + + // Single shared Ktor client for all debug logs. + private val client = HttpClient(OkHttp) + + // Lightweight scope for fire-and-forget debug logging. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + actual fun log(payload: DebugLogPayload) { + // Network logging is best-effort; never throw from here. + val safeMessage = payload.message.replace("\"", "'") + val safeLocation = payload.location.replace("\"", "'") + val json = buildString { + append('{') + append("\"sessionId\":\"").append(payload.sessionId).append('"') + append(",\"runId\":\"").append(payload.runId).append('"') + append(",\"hypothesisId\":\"").append(payload.hypothesisId).append('"') + append(",\"location\":\"").append(safeLocation).append('"') + append(",\"message\":\"").append(safeMessage).append('"') + append(",\"data\":").append(payload.data) + append(",\"timestamp\":").append(payload.timestamp) + append('}') + } + + scope.launch { + try { + client.post(ENDPOINT) { + contentType(ContentType.Application.Json) + header("X-Debug-Session-Id", SESSION_ID) + setBody(json) + } + } catch (_: Exception) { + // Swallow all errors in debug logger. + } + } + } +} + diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt new file mode 100644 index 0000000..b3a9fb0 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.android.kt @@ -0,0 +1,26 @@ +package ru.fromchat.platform + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import com.pr0gramm3r101.utils.UtilsLibrary.context + +actual fun openAppNotificationSettings(): Boolean = + try { + val intent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + true + } catch (_: Exception) { + false + } diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt new file mode 100644 index 0000000..bfd7cb2 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.android.kt @@ -0,0 +1,8 @@ +package ru.fromchat.ui + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.imeNestedScroll +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalLayoutApi::class) +actual fun Modifier.imeScrollWithKeyboard(): Modifier = this.imeNestedScroll() diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt new file mode 100644 index 0000000..4ad3d4e --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.android.kt @@ -0,0 +1,28 @@ +package ru.fromchat.ui.main.settings + +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.runtime.Composable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow + +@Composable +actual fun SettingsSecurityPredictiveBackHandler( + enabled: Boolean, + onProgress: (Float) -> Unit, + onCommit: () -> Unit, + onCancel: () -> Unit, +) { + PredictiveBackHandler(enabled = enabled) { progressFlow: Flow -> + try { + progressFlow.collect { backEvent -> + onProgress(backEvent.progress.coerceIn(0f, 1f)) + } + onCommit() + } catch (e: CancellationException) { + onCancel() + throw e + } + } +} + diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 7c61a51..53cc92d 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -116,6 +116,76 @@ Тёмное Отладка API Просмотр ответов API профиля и личных сообщений. + + Оформление + Тема и Material You + Сервер и инструменты + Смена сервера, отладка API + Уведомления + Системные настройки уведомлений + Устройства + Активные сессии + Безопасность + Смена пароля + Аккаунт + Выйти или удалить аккаунт + + Уведомления + Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона. + Открыть настройки уведомлений + + Устройства + Нет активных сессий + Когда вы войдёте с других телефонов или в браузере, они появятся здесь. + Неизвестное устройство + Активность: %1$s + Вы вошли на этом устройстве. + Это устройство + Выйти + Сведения о сессии + ID сессии + Имя устройства + Тип устройства + Система + Версия системы + Браузер + Версия браузера + Бренд + Модель + Вход + Активность + Выйти на этом устройстве + Выход… + Выйти на всех других устройствах + Выйти везде, кроме этого? + Вы останетесь в аккаунте только на этом устройстве. + Подтвердить + Отмена + + Безопасность + Текущий пароль + Новый пароль + Новый пароль ещё раз + Выйти на всех других устройствах после смены + Сменить пароль + Обновите пароль для входа + Пароль обновлён + Текущий пароль + Введите пароль, которым пользуетесь сейчас. + Новый пароль + Придумайте надёжный пароль, который вы здесь ещё не использовали. + Подтверждение пароля + Введите новый пароль ещё раз, чтобы убедиться, что без ошибок. + Далее + Версия, ссылки и другое + + Аккаунт + Удалить аккаунт + Безвозвратно удалить аккаунт и данные + Удалить аккаунт? + Это нельзя отменить. + Аккаунт удалён + Что-то пошло не так Неверное имя пользователя или пароль Не удалось подключиться. Проверьте интернет. diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index b376422..d588e3e 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -146,6 +146,76 @@ Debug API Inspect profile and DM endpoints used by the client. + + Appearance + Theme and Material You + Server and tools + Change server, debug API + Notifications + Open system notification settings + Devices + Signed-in sessions + Security + Change password + Account + Log out or delete account + + Notifications + To allow or block alerts from FromChat, use your phone’s notification settings. + Open notification settings + + Devices + No active sessions + When you sign in on other phones or browsers, they will show up here. + Unknown device + Last active %1$s + You are signed in here. + This device + Sign out + Session details + Session ID + Device name + Device type + System + System version + Browser + Browser version + Brand + Model + Signed in + Last active + Sign out on this device + Signing out… + Sign out all other devices + Sign out everywhere else? + You stay signed in on this device only. + Confirm + Cancel + + Security + Current password + New password + Confirm new password + Sign out all other devices after change + Change password + Update the password you use to sign in + Password updated + Current password + Enter the password you use now to continue. + New password + Choose a strong password you have not used here before. + Confirm new password + Type your new password again to make sure it matches. + Next + Version, links, and more + + Account + Delete account + Permanently delete your account and data + Delete account? + This cannot be undone. + Account deleted + 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 0574fb0..eeec633 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -445,21 +445,89 @@ object ApiClient { } } - suspend fun logout() { - runCatching { - http.get("${Config.apiBaseUrl}/logout") - } + suspend fun listDevices(): List = + http + .get("${Config.apiBaseUrl}/devices") { + contentType(ContentType.Application.Json) + } + .body() + .devices + suspend fun revokeDeviceSession(sessionId: String) { + http.delete("${Config.apiBaseUrl}/devices/$sessionId") { + contentType(ContentType.Application.Json) + } + } + + suspend fun revokeAllOtherDeviceSessions() { + http.post("${Config.apiBaseUrl}/devices/logout-all") { + contentType(ContentType.Application.Json) + } + } + + suspend fun changePassword( + currentPasswordDerived: String, + newPasswordDerived: String, + logoutAllExceptCurrent: Boolean + ) { + http.post("${Config.apiBaseUrl}/change-password") { + contentType(ContentType.Application.Json) + setBody( + ChangePasswordApiRequest( + currentPasswordDerived = currentPasswordDerived, + newPasswordDerived = newPasswordDerived, + logoutAllExceptCurrent = logoutAllExceptCurrent + ) + ) + } + } + + /** + * Self-delete account. Tries `/account/delete` (web client); falls back to `/delete` (bare FastAPI route) on 404. + */ + suspend fun deleteAccount(): SimpleStatusResponse { + try { + return http + .post("${Config.apiBaseUrl}/account/delete") { + contentType(ContentType.Application.Json) + } + .body() + } catch (e: ClientRequestException) { + if (e.response.status.value != 404) throw e + return http + .post("${Config.apiBaseUrl}/delete") { + contentType(ContentType.Application.Json) + } + .body() + } + } + + /** + * Clears tokens, caches, and crypto material without calling the server (use after account deletion + * or together with [logout] after remote logout). + */ + suspend fun clearLocalSession() { + val uid = user?.id secureSettings.remove("auth_token") settings.remove("user_info") settings.remove("current_user_id") token = null user = null + uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } + UpdateSyncManager.resetInMemoryOnLogout() + runCatching { IdentityKeyManager.clearLocalKeys() } runCatching { ProfileCache.clear() } runCatching { DmPanelCache.clearAll() } runCatching { PublicChatPanelCache.clear() } } + suspend fun logout() { + runCatching { + http.get("${Config.apiBaseUrl}/logout") + } + clearLocalSession() + } + fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") // WebSocket send helpers diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index c3a07e8..662d6b2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -79,6 +79,41 @@ data class LoginResponse( val token: String ) +@Serializable +data class DevicesListResponse( + val devices: List = emptyList() +) + +@Serializable +data class DeviceSessionInfo( + @SerialName("session_id") val sessionId: String, + @SerialName("device_name") val deviceName: String? = null, + @SerialName("device_type") val deviceType: String? = null, + @SerialName("os_name") val osName: String? = null, + @SerialName("os_version") val osVersion: String? = null, + @SerialName("browser_name") val browserName: String? = null, + @SerialName("browser_version") val browserVersion: String? = null, + val brand: String? = null, + val model: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("last_seen") val lastSeen: String? = null, + val revoked: Boolean? = null, + val current: Boolean = false +) + +@Serializable +data class ChangePasswordApiRequest( + val currentPasswordDerived: String, + val newPasswordDerived: String, + val logoutAllExceptCurrent: Boolean = false +) + +@Serializable +data class SimpleStatusResponse( + val status: String? = null, + val message: String? = null +) + @Serializable data class MessagesResponse( val status: String, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt index bb6e88e..2f1ca14 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/UpdateSyncManager.kt @@ -59,6 +59,19 @@ object UpdateSyncManager { ConnectionStateStore.updateSeqAndMissed(lastSeq = _lastSeq.value, missedCount = missedCount) } + fun resetInMemoryOnLogout() { + _lastSeq.value = 0 + _lastMissedCount.value = null + gapDetectionInProgress = false + ConnectionStateStore.updateSeqAndMissed(lastSeq = 0, missedCount = null) + } + + suspend fun clearPersistedSeqForUser(userId: Int) { + runCatching { + settings.remove("updates_last_seq_user_$userId") + } + } + /** * Ask the backend for missed updates between our lastSeq and the current sequence. * This call is idempotent while in progress and will no-op if there is no active diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt index 84b2923..f9098bf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/Settings.kt @@ -1,11 +1,14 @@ package ru.fromchat.core -import com.pr0gramm3r101.utils.settings.Settings +import com.pr0gramm3r101.utils.settings.Settings as PlatformSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import ru.fromchat.api.DeviceSessionInfo import ru.fromchat.ui.Theme /** @@ -21,8 +24,10 @@ object Settings { private const val THEME_KEY = "theme" private const val SERVER_URL_KEY = "server_url" private const val HTTPS_ENABLED_KEY = "https_enabled" + private const val DEVICE_SESSIONS_CACHE_KEY = "device_sessions_cache_v1" - private val settings = Settings() + private val settings = PlatformSettings() + private val deviceSessionsJson = Json { ignoreUnknownKeys = true } private fun runIO(block: suspend CoroutineScope.() -> Unit) { CoroutineScope(Dispatchers.IO).launch(block = block) @@ -78,5 +83,31 @@ object Settings { serverUrl = value.serverUrl httpsEnabled = value.httpsEnabled } + + /** Cached device sessions (JSON). Shown immediately while refreshing from the network. */ + fun readDeviceSessionsCache(): List? = runBlocking { + val raw = settings.getString(DEVICE_SESSIONS_CACHE_KEY) + if (raw.isEmpty()) return@runBlocking null + runCatching { + deviceSessionsJson.decodeFromString( + ListSerializer(DeviceSessionInfo.serializer()), + raw + ) + }.getOrNull() + } + + fun writeDeviceSessionsCache(list: List) { + runIO { + val enc = deviceSessionsJson.encodeToString( + ListSerializer(DeviceSessionInfo.serializer()), + list + ) + settings.putString(DEVICE_SESSIONS_CACHE_KEY, enc) + } + } + + fun clearDeviceSessionsCache() { + runIO { settings.remove(DEVICE_SESSIONS_CACHE_KEY) } + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt index 883694b..b4bb3b9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/crypto/IdentityKeyManager.kt @@ -119,6 +119,17 @@ object IdentityKeyManager { } } + /** + * Clears in-memory keys and secure storage (call on logout / account deletion). + */ + suspend fun clearLocalKeys() { + currentKeys = null + runCatching { + secureSettings.remove("identity_public_key") + secureSettings.remove("identity_private_key") + } + } + /** * Get current keys from memory (non-suspend, returns cached keys only) */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt new file mode 100644 index 0000000..7671ef4 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/debug/DebugLogger.kt @@ -0,0 +1,16 @@ +package ru.fromchat.debug + +data class DebugLogPayload( + val sessionId: String = "9be525", + val runId: String, + val hypothesisId: String, + val location: String, + val message: String, + val data: String, + val timestamp: Long, +) + +expect object DebugLogger { + fun log(payload: DebugLogPayload) +} + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt new file mode 100644 index 0000000..3a0a5b5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.kt @@ -0,0 +1,7 @@ +package ru.fromchat.platform + +/** + * Opens the system screen where the user can change notification permission and channels for this app. + * @return true if an intent/URL was fired (best effort). + */ +expect fun openAppNotificationSettings(): Boolean 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 5ead284..b2c3e45 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -48,9 +48,46 @@ import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.setup.ServerConfigScreen import ru.fromchat.ui.LocalSystemBarsVisibility import ru.fromchat.ui.rememberSystemBarsController +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import ru.fromchat.ui.main.settings.SettingsAccountScreen +import ru.fromchat.ui.main.settings.SettingsAppearanceScreen +import ru.fromchat.ui.main.settings.SettingsDevicesScreen +import ru.fromchat.ui.main.settings.SettingsNotificationsScreen +import ru.fromchat.ui.main.settings.SettingsRoutes +import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen +import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen +import ru.fromchat.ui.main.settings.SettingsServerToolsScreen val LocalNavController = compositionLocalOf { error("NavController not provided") } +private fun NavGraphBuilder.settingsSlideComposable( + route: String, + animationSpec: FiniteAnimationSpec, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + composable( + route = route, + enterTransition = { + slideInHorizontally(animationSpec = animationSpec) { it } + }, + exitTransition = { + slideOutHorizontally(animationSpec = animationSpec) { -it } + }, + popEnterTransition = { + slideInHorizontally(animationSpec = animationSpec) { -it } + }, + popExitTransition = { + slideOutHorizontally(animationSpec = animationSpec) { it } + }, + content = content, + ) +} + @Composable fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { setSingletonImageLoaderFactory { context -> @@ -199,13 +236,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { } composable("chat") { - MainScreen( - onLogout = { - navController.navigate("login") { - popUpTo("chat") { inclusive = true } - } - } - ) + MainScreen() } composable("chats/publicChat") { @@ -307,9 +338,51 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { ) } - composable("about") { + settingsSlideComposable("about", animationSpec) { AboutScreen() } + + val navigateToLoginClearingChat = { + navController.navigate("login") { + popUpTo("chat") { inclusive = true } + } + } + + settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) { + SettingsAppearanceScreen(onBack = { navController.navigateUp() }) + } + settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) { + SettingsServerToolsScreen( + onBack = { navController.navigateUp() }, + outerNav = navController + ) + } + settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) { + SettingsNotificationsScreen(onBack = { navController.navigateUp() }) + } + settingsSlideComposable(SettingsRoutes.Devices, animationSpec) { + SettingsDevicesScreen(onBack = { navController.navigateUp() }) + } + settingsSlideComposable(SettingsRoutes.Security, animationSpec) { + SettingsSecurityHubScreen( + onBack = { navController.navigateUp() }, + onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } + ) + } + settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) { + SettingsSecurityPasswordFlowScreen( + onBack = { navController.navigateUp() }, + onDonePopToHub = { + navController.popBackStack(SettingsRoutes.Security, inclusive = false) + } + ) + } + settingsSlideComposable(SettingsRoutes.Account, animationSpec) { + SettingsAccountScreen( + onBack = { navController.navigateUp() }, + onLogout = navigateToLoginClearingChat + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt new file mode 100644 index 0000000..ca3965e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.kt @@ -0,0 +1,9 @@ +package ru.fromchat.ui + +import androidx.compose.ui.Modifier + +/** + * Android: ties scroll to IME insets for smoother keyboard transitions (see Compose keyboard animations). + * Other platforms: no-op. + */ +expect fun Modifier.imeScrollWithKeyboard(): Modifier diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index ede4305..7bbfe92 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -27,6 +27,7 @@ import ru.fromchat.Res import ru.fromchat.* import ru.fromchat.api.ApiClient import ru.fromchat.utils.exclude +import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen private const val PAGE_CHATS = 0 @@ -37,7 +38,7 @@ private const val PAGE_COUNT = 4 @Suppress("AssignedValueIsNeverRead") @Composable -fun MainScreen(onLogout: () -> Unit = {}) { +fun MainScreen() { val pagerState = rememberPagerState( initialPage = PAGE_CHATS, pageCount = { PAGE_COUNT }, @@ -97,7 +98,7 @@ fun MainScreen(onLogout: () -> Unit = {}) { when (page) { PAGE_CHATS -> ChatsTab() PAGE_CONTACTS -> ContactsTab() - PAGE_SETTINGS -> SettingsTab(onLogout = onLogout) + PAGE_SETTINGS -> SettingsTab() PAGE_PROFILE -> { val currentUserId = ApiClient.user?.id ProfileScreen( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt deleted file mode 100644 index 9b754e4..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/SettingsTab.kt +++ /dev/null @@ -1,250 +0,0 @@ -package ru.fromchat.ui.main - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Logout -import androidx.compose.material.icons.filled.Brush -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.BugReport -import androidx.compose.material.icons.filled.Wallpaper -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumTopAppBar -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.pr0gramm3r101.components.Category -import com.pr0gramm3r101.components.ListItem -import com.pr0gramm3r101.components.SwitchListItem -import com.pr0gramm3r101.utils.materialYouAvailable -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import ru.fromchat.Res -import ru.fromchat.* -import ru.fromchat.about -import ru.fromchat.api.ApiClient -import ru.fromchat.api.WebSocketManager -import ru.fromchat.as_system -import ru.fromchat.change_server -import ru.fromchat.change_server_d -import ru.fromchat.core.Settings -import ru.fromchat.dark -import ru.fromchat.light -import ru.fromchat.logout -import ru.fromchat.materialYou -import ru.fromchat.materialYou_d -import ru.fromchat.settings -import ru.fromchat.theme -import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.Theme -import ru.fromchat.ui.dynamicThemeEnabled -import ru.fromchat.ui.theme - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun SettingsTab( - onLogout: () -> Unit -) { - TabBase { - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - - Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { - Text( - text = stringResource(Res.string.settings), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - actions = { - IconButton(onClick = { navController.navigate("about") }) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = stringResource(Res.string.about) - ) - } - }, - scrollBehavior = scrollBehavior - ) - }, - ) { innerPadding -> - Column( - Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding) - ) { - var materialYouSwitch by remember { - mutableStateOf( - Settings.materialYou && materialYouAvailable - ) - } - var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } - - Category(Modifier.padding(top = 16.dp)) { - // Material You - SwitchListItem( - headline = stringResource(Res.string.materialYou), - supportingText = stringResource(Res.string.materialYou_d), - enabled = materialYouAvailable, - checked = materialYouSwitch, - onCheckedChange = { - materialYouSwitch = it - Settings.materialYou = it - dynamicThemeEnabled = it - }, - divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp, - leadingContent = { - Icon( - imageVector = Icons.Filled.Wallpaper, - contentDescription = null - ) - } - ) - - // Theme - ListItem( - headline = stringResource(Res.string.theme), - leadingContent = { - Icon( - imageVector = Icons.Filled.Brush, - contentDescription = null - ) - }, - bottomContent = { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val options = listOf( - stringResource(Res.string.as_system), - stringResource(Res.string.light), - stringResource(Res.string.dark) - ) - options.forEachIndexed { index, label -> - FilterChip( - onClick = { - themeChipIndex = index - Settings.theme = Theme.entries[index] - theme = Theme.entries[index] - }, - selected = index == themeChipIndex, - leadingIcon = { - if (index == 0) { - Spacer(Modifier.width(16.dp)) - } - when (index) { - 0 -> Icon(Icons.Filled.Settings, null) - 1 -> Icon(Icons.Filled.LightMode, null) - 2 -> Icon(Icons.Filled.DarkMode, null) - } - }, - label = { - Text( - text = label, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - } - ) - } - - // Server Configuration - Category(Modifier.padding(top = 16.dp)) { - ListItem( - headline = stringResource(Res.string.change_server), - supportingText = stringResource(Res.string.change_server_d), - onClick = { - // Logout and navigate to server config when server changes - scope.launch { - navController.navigate("serverConfig") - } - }, - divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp, - leadingContent = { - Icon(Icons.Filled.Storage, null) - } - ) - - ListItem( - headline = stringResource(Res.string.debug_tools), - supportingText = stringResource(Res.string.debug_tools_d), - onClick = { - navController.navigate("debug") - }, - divider = true, - dividerColor = MaterialTheme.colorScheme.surface, - dividerThickness = 2.dp, - leadingContent = { - Icon(Icons.Filled.BugReport, null) - } - ) - - ListItem( - headline = stringResource(Res.string.logout), - leadingContent = { - Icon(Icons.AutoMirrored.Filled.Logout, null) - }, - onClick = { - scope.launch { - // Logout - try { - ApiClient.logout() - } catch (e: Exception) { - // Ignore logout errors - } - - // Clear API client state - ApiClient.token = null - ApiClient.user = null - - // Shutdown WebSocket - WebSocketManager.shutdown() - - // Navigate back to auth - onLogout() - } - } - ) - } - } - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt new file mode 100644 index 0000000..42b1ee0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt @@ -0,0 +1,288 @@ +package ru.fromchat.ui.main.settings + +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.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +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.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.toPath +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.lerp +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph +import androidx.graphics.shapes.RoundedPolygon + +/** Horizontal padding for stepped / full-bleed settings screens (matches key M3 spacing). */ +val SettingsStepHorizontalPadding = 24.dp + +/** Inset “cut” between rows on tinted cards: matches the window surface behind the card. */ +@Composable +fun settingsSurfaceCutDividerColor(): Color = MaterialTheme.colorScheme.surface + +val SettingsSurfaceCutDividerThickness = 2.dp + +@Composable +fun SettingsSurfaceCutDivider() { + HorizontalDivider( + color = settingsSurfaceCutDividerColor(), + thickness = SettingsSurfaceCutDividerThickness + ) +} + +/** Primary CTA shape for security flow (soft squircle). */ +val SettingsSecurityCtaShape = RoundedCornerShape(percent = 38) + +/** 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) +private fun securityHeroMaterialPolygon(step: SecurityPasswordFlowStep): RoundedPolygon = + when (step) { + SecurityPasswordFlowStep.Current -> MaterialShapes.Cookie4Sided + SecurityPasswordFlowStep.New -> MaterialShapes.Cookie6Sided + SecurityPasswordFlowStep.Confirm -> MaterialShapes.Cookie7Sided + }.normalized() + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private 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 +} + +/** + * 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 path = morph.toPath(p, Path()) + // Shapes are normalized to a 0–1 box; keep a fixed outer size so the hero + // never appears to grow or shrink between steps, only morph its outline. + val unit = minOf(size.width, size.height) * 0.94f + val s = unit + // Center at canvas origin, then draw the normalized path around (-0.5, -0.5) .. (0.5, 0.5). + translate(left = size.width / 2f, top = size.height / 2f) { + scale(scaleX = s, scaleY = s, pivot = Offset.Zero) { + translate(left = -0.5f, top = -0.5f) { + drawPath( + path = 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 + ) + } + } + } +} + +/** + * Large icon in a rounded shape for empty states and smaller heroes. + */ +@Composable +fun SettingsExpressiveIconFrame( + icon: ImageVector, + modifier: Modifier = Modifier, + containerSize: Dp = 112.dp, + iconSize: Dp = 52.dp, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer +) { + Box( + modifier = modifier + .size(containerSize) + .clip(RoundedCornerShape(percent = 32)) + .background(containerColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(iconSize), + tint = contentColor + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt new file mode 100644 index 0000000..e0d0952 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsNavHost.kt @@ -0,0 +1,28 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.settings +import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.main.TabBase + +@Composable +fun SettingsTab() { + TabBase { + val nav = LocalNavController.current + SettingsHubScreen( + onAppearance = { nav.navigate(SettingsRoutes.Appearance) }, + onServerTools = { nav.navigate(SettingsRoutes.ServerTools) }, + onNotifications = { nav.navigate(SettingsRoutes.Notifications) }, + onDevices = { nav.navigate(SettingsRoutes.Devices) }, + onSecurity = { nav.navigate(SettingsRoutes.Security) }, + onAccount = { nav.navigate(SettingsRoutes.Account) }, + onAbout = { nav.navigate("about") }, + title = stringResource(Res.string.settings), + modifier = Modifier.fillMaxSize() + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt new file mode 100644 index 0000000..1ef4c29 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.kt @@ -0,0 +1,18 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.runtime.Composable + +/** + * Cross-platform hook for Android predictive back progress on the security password flow screen. + * + * - On Android, this ties into androidx.activity.compose.PredictiveBackHandler. + * - On other platforms, it is a no-op. + */ +@Composable +expect fun SettingsSecurityPredictiveBackHandler( + enabled: Boolean, + onProgress: (Float) -> Unit, + onCommit: () -> Unit, + onCancel: () -> Unit, +) + 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 new file mode 100644 index 0000000..385f9b5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt @@ -0,0 +1,14 @@ +package ru.fromchat.ui.main.settings + +/** Root [androidx.navigation.NavController] routes for full-screen settings (slide transitions). */ +object SettingsRoutes { + const val Appearance = "settings/appearance" + const val ServerTools = "settings/server_tools" + const val Notifications = "settings/notifications" + const val Devices = "settings/devices" + /** Hub with a single action to start the password flow. */ + const val Security = "settings/security" + /** Single destination: in-screen steps + morphing hero (no nested nav routes per step). */ + const val SecurityPasswordFlow = "settings/security/password" + const val Account = "settings/account" +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt new file mode 100644 index 0000000..5318e9b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsScreens.kt @@ -0,0 +1,1789 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Brush +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.LaptopMac +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.TabletAndroid +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.components.SwitchListItem +import com.pr0gramm3r101.utils.crypto.deriveAuthSecret +import com.pr0gramm3r101.utils.materialYouAvailable +import io.ktor.client.plugins.ClientRequestException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.about +import ru.fromchat.back +import ru.fromchat.api.ApiClient +import ru.fromchat.ui.imeScrollWithKeyboard +import ru.fromchat.api.DeviceSessionInfo +import ru.fromchat.ui.main.settings.SettingsSecurityPredictiveBackHandler +import ru.fromchat.api.WebSocketManager +import ru.fromchat.cancel +import ru.fromchat.change_server +import ru.fromchat.change_server_d +import ru.fromchat.confirm +import ru.fromchat.core.Settings +import ru.fromchat.dark +import ru.fromchat.debug_tools +import ru.fromchat.debug_tools_d +import ru.fromchat.error_unexpected +import ru.fromchat.fill_all_fields +import ru.fromchat.light +import ru.fromchat.logout +import ru.fromchat.materialYou +import ru.fromchat.materialYou_d +import ru.fromchat.password_length_error +import ru.fromchat.passwords_dont_match +import ru.fromchat.platform.openAppNotificationSettings +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_title +import ru.fromchat.settings_category_account +import ru.fromchat.settings_category_account_d +import ru.fromchat.settings_category_appearance +import ru.fromchat.settings_category_appearance_d +import ru.fromchat.settings_category_devices +import ru.fromchat.settings_category_devices_d +import ru.fromchat.settings_category_notifications +import ru.fromchat.settings_category_notifications_d +import ru.fromchat.settings_category_security +import ru.fromchat.settings_category_security_d +import ru.fromchat.settings_category_server_tools +import ru.fromchat.settings_category_server_tools_d +import ru.fromchat.settings_change_password +import ru.fromchat.settings_confirm_new_password +import ru.fromchat.settings_current_password +import ru.fromchat.settings_devices_current_hint +import ru.fromchat.settings_devices_empty +import ru.fromchat.settings_devices_empty_sub +import ru.fromchat.settings_devices_field_brand +import ru.fromchat.settings_devices_field_browser +import ru.fromchat.settings_devices_field_browser_version +import ru.fromchat.settings_devices_field_device_name +import ru.fromchat.settings_devices_field_device_type +import ru.fromchat.settings_devices_field_model +import ru.fromchat.settings_devices_field_os +import ru.fromchat.settings_devices_field_os_version +import ru.fromchat.settings_devices_field_last_active +import ru.fromchat.settings_devices_field_session_id +import ru.fromchat.settings_devices_field_signed_in +import ru.fromchat.settings_devices_last_active +import ru.fromchat.settings_devices_sheet_title +import ru.fromchat.settings_devices_sign_out_sheet +import ru.fromchat.settings_devices_signing_out +import ru.fromchat.settings_devices_unknown +import ru.fromchat.settings_devices_logout_all +import ru.fromchat.settings_devices_logout_all_confirm_body +import ru.fromchat.settings_devices_logout_all_confirm_title +import ru.fromchat.settings_devices_this_device +import ru.fromchat.settings_devices_title +import ru.fromchat.settings_new_password +import ru.fromchat.settings_notifications_body +import ru.fromchat.settings_notifications_title +import ru.fromchat.settings_open_notification_settings +import ru.fromchat.settings_hub_about_sub +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_change_password_sub +import ru.fromchat.settings_security_step_new_title +import ru.fromchat.settings_security_title +import ru.fromchat.as_system +import ru.fromchat.theme +import ru.fromchat.ui.Theme +import ru.fromchat.ui.dynamicThemeEnabled +import ru.fromchat.ui.theme + +@Composable +private fun SettingsListLeadingIcon(imageVector: ImageVector) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SettingsHubScreen( + onAppearance: () -> Unit, + onServerTools: () -> Unit, + onNotifications: () -> Unit, + onDevices: () -> Unit, + onSecurity: () -> Unit, + onAccount: () -> Unit, + onAbout: () -> Unit, + title: String, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + modifier = Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ListItem( + headline = stringResource(Res.string.settings_category_appearance), + supportingText = stringResource(Res.string.settings_category_appearance_d), + onClick = onAppearance, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Palette) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness + ) + ListItem( + headline = stringResource(Res.string.settings_category_server_tools), + supportingText = stringResource(Res.string.settings_category_server_tools_d), + onClick = onServerTools, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness + ) + ListItem( + headline = stringResource(Res.string.settings_category_notifications), + supportingText = stringResource(Res.string.settings_category_notifications_d), + onClick = onNotifications, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Notifications) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness + ) + ListItem( + headline = stringResource(Res.string.settings_category_devices), + supportingText = stringResource(Res.string.settings_category_devices_d), + onClick = onDevices, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Devices) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness + ) + ListItem( + headline = stringResource(Res.string.settings_category_security), + supportingText = stringResource(Res.string.settings_category_security_d), + onClick = onSecurity, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Lock) }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness + ) + ListItem( + headline = stringResource(Res.string.settings_category_account), + supportingText = stringResource(Res.string.settings_category_account_d), + onClick = onAccount, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) } + ) + } + Spacer(Modifier.height(20.dp)) + Category( + modifier = Modifier.padding(bottom = 24.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + val aboutInteraction = remember { MutableInteractionSource() } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = aboutInteraction, + indication = LocalIndication.current, + onClick = onAbout + ) + .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsListLeadingIcon(Icons.Filled.Info) + Column(Modifier.padding(horizontal = 16.dp).weight(1f)) { + Text( + text = stringResource(Res.string.about), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(Res.string.settings_hub_about_sub), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SettingsAppearanceScreen(onBack: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + var materialYouSwitch by remember { + mutableStateOf(Settings.materialYou && materialYouAvailable) + } + var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_category_appearance)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + SwitchListItem( + headline = stringResource(Res.string.materialYou), + supportingText = stringResource(Res.string.materialYou_d), + enabled = materialYouAvailable, + checked = materialYouSwitch, + onCheckedChange = { + materialYouSwitch = it + Settings.materialYou = it + dynamicThemeEnabled = it + }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness, + leadingContent = { + SettingsListLeadingIcon(Icons.Filled.Wallpaper) + } + ) + ListItem( + headline = stringResource(Res.string.theme), + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Brush) }, + bottomContent = { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val options = listOf( + stringResource(Res.string.as_system), + stringResource(Res.string.light), + stringResource(Res.string.dark) + ) + options.forEachIndexed { index, label -> + FilterChip( + onClick = { + themeChipIndex = index + Settings.theme = Theme.entries[index] + theme = Theme.entries[index] + }, + selected = index == themeChipIndex, + leadingIcon = { + if (index == 0) { + Spacer(Modifier.width(16.dp)) + } + when (index) { + 0 -> Icon(Icons.Filled.Settings, null) + 1 -> Icon(Icons.Filled.LightMode, null) + 2 -> Icon(Icons.Filled.DarkMode, null) + else -> Unit + } + }, + label = { + Text(text = label, overflow = TextOverflow.Ellipsis) + } + ) + } + } + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsServerToolsScreen(onBack: () -> Unit, outerNav: NavController) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_category_server_tools)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ListItem( + headline = stringResource(Res.string.change_server), + supportingText = stringResource(Res.string.change_server_d), + onClick = { outerNav.navigate("serverConfig") }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Storage) } + ) + ListItem( + headline = stringResource(Res.string.debug_tools), + supportingText = stringResource(Res.string.debug_tools_d), + onClick = { outerNav.navigate("debug") }, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.BugReport) } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SettingsSecurityHubScreen(onBack: () -> Unit, onChangePassword: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_security_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + modifier = Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ListItem( + headline = stringResource(Res.string.settings_change_password), + supportingText = stringResource(Res.string.settings_security_change_password_sub), + onClick = onChangePassword, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.Key) } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsNotificationsScreen(onBack: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_notifications_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + modifier = Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Text( + text = stringResource(Res.string.settings_notifications_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 8.dp) + ) + FilledTonalButton( + onClick = { openAppNotificationSettings() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + Text(stringResource(Res.string.settings_open_notification_settings)) + } + } + } + } +} + +private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String): String { + val parts = listOfNotNull(d.deviceName, d.osName, d.browserName).filter { it.isNotBlank() } + return if (parts.isNotEmpty()) parts.joinToString(" • ") else (d.deviceType?.takeIf { it.isNotBlank() } ?: fallbackLabel) +} + +private fun formatDeviceLastSeen(iso: String?): String { + if (iso.isNullOrBlank()) return "—" + return iso.replace("T", " ").take(19) +} + +private fun deviceHeadline(d: DeviceSessionInfo, fallback: String): String { + d.deviceName?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + val brandModel = listOfNotNull(d.brand?.trim(), d.model?.trim()) + .filter { it.isNotEmpty() } + .joinToString(" ") + if (brandModel.isNotEmpty()) return brandModel + return formatDeviceLine(d, fallback) +} + +private fun deviceDetailLine(d: DeviceSessionInfo): String? { + val parts = buildList { + d.osName?.takeIf { it.isNotBlank() }?.let { add(it.trim()) } + d.browserName?.takeIf { it.isNotBlank() }?.let { add(it.trim()) } + } + return if (parts.isNotEmpty()) parts.joinToString(" · ") else null +} + +private fun deviceSessionIcon(d: DeviceSessionInfo): ImageVector { + val type = d.deviceType?.lowercase().orEmpty() + val os = d.osName?.lowercase().orEmpty() + val hasBrowser = d.browserName?.isNotBlank() == true + return when { + "tablet" in type -> Icons.Filled.TabletAndroid + "mobile" in type || "phone" in type -> Icons.Filled.PhoneAndroid + "android" in os || "ios" in os || "iphone" in os -> Icons.Filled.PhoneAndroid + hasBrowser && "windows" !in os && "mac" !in os && "linux" !in os && + "android" !in os && "ios" !in os -> Icons.Filled.Language + hasBrowser -> Icons.Filled.Language + else -> Icons.Filled.LaptopMac + } +} + +@Composable +private fun DeviceSessionDetailParam(label: String, value: String?) { + if (value.isNullOrBlank()) return + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun DeviceSessionDetailBottomSheet( + d: DeviceSessionInfo, + unknownDeviceLabel: String, + signingOut: Boolean, + onDismiss: () -> Unit, + onConfirmSignOut: () -> Unit +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = 24.dp) + .padding(bottom = 20.dp) + ) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.settings_devices_sheet_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(8.dp)) + Text( + text = deviceHeadline(d, unknownDeviceLabel), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(16.dp)) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_session_id), + d.sessionId + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_device_name), + d.deviceName + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_device_type), + d.deviceType + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_os), + d.osName + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_os_version), + d.osVersion + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_browser), + d.browserName + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_browser_version), + d.browserVersion + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_brand), + d.brand + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_model), + d.model + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_signed_in), + d.createdAt?.replace("T", " ")?.take(19) + ) + DeviceSessionDetailParam( + stringResource(Res.string.settings_devices_field_last_active), + formatDeviceLastSeen(d.lastSeen).takeIf { it != "—" } + ) + HorizontalDivider( + Modifier.padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + if (d.current) { + FilledTonalButton( + onClick = { }, + enabled = false, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(Res.string.settings_devices_this_device)) + } + } else { + Button( + onClick = onConfirmSignOut, + enabled = !signingOut, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp), + shape = RoundedCornerShape(16.dp), + contentPadding = ButtonDefaults.ContentPadding + ) { + Box( + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = signingOut, + transitionSpec = { + (fadeIn(tween(200)) + slideInVertically { it / 4 }) togetherWith + (fadeOut(tween(200)) + slideOutVertically { -it / 4 }) + }, + label = "device_sign_out" + ) { busy -> + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(stringResource(Res.string.settings_devices_sign_out_sheet)) + } + } + } + } + } + TextButton( + onClick = onDismiss, + enabled = !signingOut, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(Res.string.cancel)) + } + } +} + +@Composable +private fun DeviceSessionRow( + d: DeviceSessionInfo, + unknownDeviceLabel: String, + onOpen: () -> Unit +) { + val headline = remember(d) { deviceHeadline(d, unknownDeviceLabel) } + val detail = remember(d) { deviceDetailLine(d) } + val interaction = remember(d.sessionId) { MutableInteractionSource() } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = interaction, + indication = LocalIndication.current, + onClick = onOpen + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = deviceSessionIcon(d), + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(start = 14.dp, end = 8.dp) + ) { + Text( + text = headline, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (d.current) { + Spacer(Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(100), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = stringResource(Res.string.settings_devices_this_device), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp) + ) + } + } + detail?.let { line -> + Spacer(Modifier.height(6.dp)) + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.height(6.dp)) + Text( + text = if (d.current) { + stringResource(Res.string.settings_devices_current_hint) + } else { + stringResource( + Res.string.settings_devices_last_active, + formatDeviceLastSeen(d.lastSeen) + ) + }, + style = MaterialTheme.typography.bodySmall, + color = if (d.current) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsDevicesScreen(onBack: () -> Unit) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val initialCache = remember { Settings.readDeviceSessionsCache() } + var devices by remember { mutableStateOf?>(initialCache) } + var loading by remember { mutableStateOf(initialCache == null) } + var sheetDevice by remember { mutableStateOf(null) } + var sheetSigningOut by remember { mutableStateOf(false) } + var showLogoutAllConfirm by remember { mutableStateOf(false) } + val errUnexpected = stringResource(Res.string.error_unexpected) + val unknownDeviceLabel = stringResource(Res.string.settings_devices_unknown) + + LaunchedEffect(sheetDevice) { + sheetSigningOut = false + } + + fun reload() { + scope.launch { + if (devices == null) loading = true + runCatching { ApiClient.listDevices() } + .onSuccess { + devices = it + Settings.writeDeviceSessionsCache(it) + } + .onFailure { + if (devices == null) { + devices = emptyList() + } + snackbarHostState.showSnackbar(errUnexpected) + } + loading = false + } + } + + LaunchedEffect(Unit) { + reload() + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_devices_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + when { + loading -> { + Column( + Modifier.fillMaxSize().padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + devices.isNullOrEmpty() -> { + Column( + Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + SettingsExpressiveIconFrame( + icon = Icons.Filled.Devices, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(Res.string.settings_devices_empty), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.settings_devices_empty_sub), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + val sessionList = devices!! + LazyColumn( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item(key = "sign_out_others") { + FilledTonalButton( + onClick = { showLogoutAllConfirm = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(Res.string.settings_devices_logout_all)) + } + } + item(key = "sessions_card") { + Column(Modifier.fillMaxWidth()) { + Category( + modifier = Modifier.fillMaxWidth(), + margin = PaddingValues(0.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + sessionList.forEachIndexed { index, d -> + DeviceSessionRow( + d = d, + unknownDeviceLabel = unknownDeviceLabel, + onOpen = { sheetDevice = d } + ) + if (index < sessionList.lastIndex) { + SettingsSurfaceCutDivider() + } + } + } + } + } + } + } + } + } + + sheetDevice?.let { d -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { if (!sheetSigningOut) sheetDevice = null }, + sheetState = sheetState + ) { + DeviceSessionDetailBottomSheet( + d = d, + unknownDeviceLabel = unknownDeviceLabel, + signingOut = sheetSigningOut, + onDismiss = { if (!sheetSigningOut) sheetDevice = null }, + onConfirmSignOut = { + val id = d.sessionId + scope.launch { + sheetSigningOut = true + val result = runCatching { ApiClient.revokeDeviceSession(id) } + sheetSigningOut = false + result + .onSuccess { + sheetDevice = null + reload() + } + .onFailure { + snackbarHostState.showSnackbar(errUnexpected) + } + } + } + ) + } + } + + if (showLogoutAllConfirm) { + AlertDialog( + onDismissRequest = { showLogoutAllConfirm = false }, + title = { Text(stringResource(Res.string.settings_devices_logout_all_confirm_title)) }, + text = { Text(stringResource(Res.string.settings_devices_logout_all_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + showLogoutAllConfirm = false + scope.launch { + runCatching { ApiClient.revokeAllOtherDeviceSessions() } + .onSuccess { reload() } + .onFailure { snackbarHostState.showSnackbar(errUnexpected) } + } + } + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutAllConfirm = false }) { + Text(stringResource(Res.string.cancel)) + } + } + ) + } +} + +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 snackbarToken by remember { mutableIntStateOf(0) } + + 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 -> + snackbarToken++ + val currentToken = snackbarToken + scope.launch { + snackbarHostState.showSnackbar( + message = text, + withDismissAction = false, + duration = SnackbarDuration.Indefinite, + ) + } + scope.launch { + delay(1000L) + if (snackbarToken == currentToken) { + snackbarHostState.currentSnackbarData?.dismiss() + } + } + } + + 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 = pagerState.currentPageOffsetFraction + + var predictiveFromStep by remember { mutableStateOf(null) } + var predictiveToStep by remember { mutableStateOf(null) } + var predictiveProgress by remember { mutableFloatStateOf(0f) } + val predictiveThreshold = 0.15f + + SettingsSecurityPredictiveBackHandler( + 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 { + val start = lastProgress + val anim = Animatable(start) + anim.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 { + val start = lastProgress + val anim = Animatable(start) + anim.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@SettingsSecurityPredictiveBackHandler + } + scope.launch { + val start = lastProgress + val target = if (lastProgress >= predictiveThreshold) 1f else 0f + val anim = Animatable(start) + anim.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 = SettingsSecurityCtaShape, + 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 = SettingsSecurityCtaShape, + 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 = SettingsSecurityCtaShape, + 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)) + } + } + } + } + } + } + } + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(bottom = 76.dp) + .fillMaxWidth(), + snackbar = { data -> + Snackbar( + snackbarData = data, + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + 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, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsAccountScreen(onBack: () -> Unit, onLogout: () -> 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) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + MediumTopAppBar( + title = { Text(stringResource(Res.string.settings_account_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + ) { + Category( + Modifier.padding(top = 16.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ListItem( + headline = stringResource(Res.string.logout), + onClick = { + scope.launch { + runCatching { ApiClient.logout() } + WebSocketManager.shutdown() + onLogout() + } + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Filled.Logout, + null, + Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + divider = true, + dividerColor = settingsSurfaceCutDividerColor(), + dividerThickness = SettingsSurfaceCutDividerThickness + ) + ListItem( + headline = stringResource(Res.string.settings_account_delete), + supportingText = stringResource(Res.string.settings_account_delete_d), + onClick = { showDeleteConfirm = true }, + leadingContent = { SettingsListLeadingIcon(Icons.Filled.AccountCircle) } + ) + } + } + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(stringResource(Res.string.settings_account_delete_confirm_title)) }, + text = { Text(stringResource(Res.string.settings_account_delete_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + scope.launch { + runCatching { + ApiClient.deleteAccount() + WebSocketManager.shutdown() + ApiClient.clearLocalSession() + onLogout() + }.onFailure { + snackbarHostState.showSnackbar(it.message ?: errUnexpected) + } + } + } + ) { + Text(stringResource(Res.string.confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(Res.string.cancel)) + } + } + ) + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt new file mode 100644 index 0000000..d3d3e6f --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/debug/DebugLogger.ios.kt @@ -0,0 +1,8 @@ +package ru.fromchat.debug + +actual object DebugLogger { + actual fun log(payload: DebugLogPayload) { + // iOS target: no-op for debug logging in this session. + } +} + diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt new file mode 100644 index 0000000..036d8ff --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/platform/OpenAppNotificationSettings.ios.kt @@ -0,0 +1,12 @@ +package ru.fromchat.platform + +import platform.Foundation.NSURL +import platform.UIKit.UIApplication +import platform.UIKit.UIApplicationOpenSettingsURLString + +@Suppress("unused") +actual fun openAppNotificationSettings(): Boolean { + val urlString = UIApplicationOpenSettingsURLString + val url = NSURL.URLWithString(urlString) ?: return false + return UIApplication.sharedApplication.openURL(url) +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt new file mode 100644 index 0000000..d0ce02f --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/ImeScrollWithKeyboard.ios.kt @@ -0,0 +1,5 @@ +package ru.fromchat.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.imeScrollWithKeyboard(): Modifier = this diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt new file mode 100644 index 0000000..9c5924b --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/main/settings/SettingsPredictiveBack.ios.kt @@ -0,0 +1,15 @@ +package ru.fromchat.ui.main.settings + +import androidx.compose.runtime.Composable + +@Composable +actual fun SettingsSecurityPredictiveBackHandler( + enabled: Boolean, + onProgress: (Float) -> Unit, + onCommit: () -> Unit, + onCancel: () -> Unit, +) { + // iOS has no Android predictive back; keep state reset. + onProgress(0f) +} +