Implement new settings

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-07 22:01:19 +03:00
Unverified
parent 135ac010c3
commit 87934c7448
30 changed files with 2752 additions and 271 deletions
@@ -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.
+16
View File
@@ -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.
+5 -1
View File
@@ -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.
+16 -4
View File
@@ -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.
@@ -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.
}
}
}
}
@@ -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
}
@@ -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()
@@ -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<BackEventCompat> ->
try {
progressFlow.collect { backEvent ->
onProgress(backEvent.progress.coerceIn(0f, 1f))
}
onCommit()
} catch (e: CancellationException) {
onCancel()
throw e
}
}
}
@@ -116,6 +116,76 @@
<string name="dark">Тёмное</string>
<string name="debug_tools">Отладка API</string>
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
<string name="settings_category_appearance">Оформление</string>
<string name="settings_category_appearance_d">Тема и Material You</string>
<string name="settings_category_server_tools">Сервер и инструменты</string>
<string name="settings_category_server_tools_d">Смена сервера, отладка API</string>
<string name="settings_category_notifications">Уведомления</string>
<string name="settings_category_notifications_d">Системные настройки уведомлений</string>
<string name="settings_category_devices">Устройства</string>
<string name="settings_category_devices_d">Активные сессии</string>
<string name="settings_category_security">Безопасность</string>
<string name="settings_category_security_d">Смена пароля</string>
<string name="settings_category_account">Аккаунт</string>
<string name="settings_category_account_d">Выйти или удалить аккаунт</string>
<string name="settings_notifications_title">Уведомления</string>
<string name="settings_notifications_body">Чтобы разрешить или отключить уведомления FromChat, откройте настройки уведомлений телефона.</string>
<string name="settings_open_notification_settings">Открыть настройки уведомлений</string>
<string name="settings_devices_title">Устройства</string>
<string name="settings_devices_empty">Нет активных сессий</string>
<string name="settings_devices_empty_sub">Когда вы войдёте с других телефонов или в браузере, они появятся здесь.</string>
<string name="settings_devices_unknown">Неизвестное устройство</string>
<string name="settings_devices_last_active">Активность: %1$s</string>
<string name="settings_devices_current_hint">Вы вошли на этом устройстве.</string>
<string name="settings_devices_this_device">Это устройство</string>
<string name="settings_devices_revoke">Выйти</string>
<string name="settings_devices_sheet_title">Сведения о сессии</string>
<string name="settings_devices_field_session_id">ID сессии</string>
<string name="settings_devices_field_device_name">Имя устройства</string>
<string name="settings_devices_field_device_type">Тип устройства</string>
<string name="settings_devices_field_os">Система</string>
<string name="settings_devices_field_os_version">Версия системы</string>
<string name="settings_devices_field_browser">Браузер</string>
<string name="settings_devices_field_browser_version">Версия браузера</string>
<string name="settings_devices_field_brand">Бренд</string>
<string name="settings_devices_field_model">Модель</string>
<string name="settings_devices_field_signed_in">Вход</string>
<string name="settings_devices_field_last_active">Активность</string>
<string name="settings_devices_sign_out_sheet">Выйти на этом устройстве</string>
<string name="settings_devices_signing_out">Выход…</string>
<string name="settings_devices_logout_all">Выйти на всех других устройствах</string>
<string name="settings_devices_logout_all_confirm_title">Выйти везде, кроме этого?</string>
<string name="settings_devices_logout_all_confirm_body">Вы останетесь в аккаунте только на этом устройстве.</string>
<string name="confirm">Подтвердить</string>
<string name="cancel">Отмена</string>
<string name="settings_security_title">Безопасность</string>
<string name="settings_current_password">Текущий пароль</string>
<string name="settings_new_password">Новый пароль</string>
<string name="settings_confirm_new_password">Новый пароль ещё раз</string>
<string name="settings_logout_other_sessions">Выйти на всех других устройствах после смены</string>
<string name="settings_change_password">Сменить пароль</string>
<string name="settings_security_change_password_sub">Обновите пароль для входа</string>
<string name="settings_password_changed">Пароль обновлён</string>
<string name="settings_security_step_current_title">Текущий пароль</string>
<string name="settings_security_step_current_body">Введите пароль, которым пользуетесь сейчас.</string>
<string name="settings_security_step_new_title">Новый пароль</string>
<string name="settings_security_step_new_body">Придумайте надёжный пароль, который вы здесь ещё не использовали.</string>
<string name="settings_security_step_confirm_title">Подтверждение пароля</string>
<string name="settings_security_step_confirm_body">Введите новый пароль ещё раз, чтобы убедиться, что без ошибок.</string>
<string name="settings_next">Далее</string>
<string name="settings_hub_about_sub">Версия, ссылки и другое</string>
<string name="settings_account_title">Аккаунт</string>
<string name="settings_account_delete">Удалить аккаунт</string>
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
<string name="settings_account_deleted">Аккаунт удалён</string>
<string name="error_unexpected">Что-то пошло не так</string>
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
@@ -146,6 +146,76 @@
<string name="debug_tools">Debug API</string>
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
<!-- Settings categories -->
<string name="settings_category_appearance">Appearance</string>
<string name="settings_category_appearance_d">Theme and Material You</string>
<string name="settings_category_server_tools">Server and tools</string>
<string name="settings_category_server_tools_d">Change server, debug API</string>
<string name="settings_category_notifications">Notifications</string>
<string name="settings_category_notifications_d">Open system notification settings</string>
<string name="settings_category_devices">Devices</string>
<string name="settings_category_devices_d">Signed-in sessions</string>
<string name="settings_category_security">Security</string>
<string name="settings_category_security_d">Change password</string>
<string name="settings_category_account">Account</string>
<string name="settings_category_account_d">Log out or delete account</string>
<string name="settings_notifications_title">Notifications</string>
<string name="settings_notifications_body">To allow or block alerts from FromChat, use your phones notification settings.</string>
<string name="settings_open_notification_settings">Open notification settings</string>
<string name="settings_devices_title">Devices</string>
<string name="settings_devices_empty">No active sessions</string>
<string name="settings_devices_empty_sub">When you sign in on other phones or browsers, they will show up here.</string>
<string name="settings_devices_unknown">Unknown device</string>
<string name="settings_devices_last_active">Last active %1$s</string>
<string name="settings_devices_current_hint">You are signed in here.</string>
<string name="settings_devices_this_device">This device</string>
<string name="settings_devices_revoke">Sign out</string>
<string name="settings_devices_sheet_title">Session details</string>
<string name="settings_devices_field_session_id">Session ID</string>
<string name="settings_devices_field_device_name">Device name</string>
<string name="settings_devices_field_device_type">Device type</string>
<string name="settings_devices_field_os">System</string>
<string name="settings_devices_field_os_version">System version</string>
<string name="settings_devices_field_browser">Browser</string>
<string name="settings_devices_field_browser_version">Browser version</string>
<string name="settings_devices_field_brand">Brand</string>
<string name="settings_devices_field_model">Model</string>
<string name="settings_devices_field_signed_in">Signed in</string>
<string name="settings_devices_field_last_active">Last active</string>
<string name="settings_devices_sign_out_sheet">Sign out on this device</string>
<string name="settings_devices_signing_out">Signing out…</string>
<string name="settings_devices_logout_all">Sign out all other devices</string>
<string name="settings_devices_logout_all_confirm_title">Sign out everywhere else?</string>
<string name="settings_devices_logout_all_confirm_body">You stay signed in on this device only.</string>
<string name="confirm">Confirm</string>
<string name="cancel">Cancel</string>
<string name="settings_security_title">Security</string>
<string name="settings_current_password">Current password</string>
<string name="settings_new_password">New password</string>
<string name="settings_confirm_new_password">Confirm new password</string>
<string name="settings_logout_other_sessions">Sign out all other devices after change</string>
<string name="settings_change_password">Change password</string>
<string name="settings_security_change_password_sub">Update the password you use to sign in</string>
<string name="settings_password_changed">Password updated</string>
<string name="settings_security_step_current_title">Current password</string>
<string name="settings_security_step_current_body">Enter the password you use now to continue.</string>
<string name="settings_security_step_new_title">New password</string>
<string name="settings_security_step_new_body">Choose a strong password you have not used here before.</string>
<string name="settings_security_step_confirm_title">Confirm new password</string>
<string name="settings_security_step_confirm_body">Type your new password again to make sure it matches.</string>
<string name="settings_next">Next</string>
<string name="settings_hub_about_sub">Version, links, and more</string>
<string name="settings_account_title">Account</string>
<string name="settings_account_delete">Delete account</string>
<string name="settings_account_delete_d">Permanently delete your account and data</string>
<string name="settings_account_delete_confirm_title">Delete account?</string>
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
<string name="settings_account_deleted">Account deleted</string>
<!-- Error Messages -->
<string name="error_unexpected">Something went wrong</string>
<string name="error_invalid_credentials">Wrong username or password</string>
@@ -445,21 +445,89 @@ object ApiClient {
}
}
suspend fun logout() {
runCatching {
http.get("${Config.apiBaseUrl}/logout")
}
suspend fun listDevices(): List<DeviceSessionInfo> =
http
.get("${Config.apiBaseUrl}/devices") {
contentType(ContentType.Application.Json)
}
.body<DevicesListResponse>()
.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
@@ -79,6 +79,41 @@ data class LoginResponse(
val token: String
)
@Serializable
data class DevicesListResponse(
val devices: List<DeviceSessionInfo> = 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,
@@ -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
@@ -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<DeviceSessionInfo>? = 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<DeviceSessionInfo>) {
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) }
}
}
@@ -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)
*/
@@ -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)
}
@@ -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
@@ -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<NavController> { error("NavController not provided") }
private fun NavGraphBuilder.settingsSlideComposable(
route: String,
animationSpec: FiniteAnimationSpec<IntOffset>,
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
)
}
}
}
}
@@ -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
@@ -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(
@@ -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()
}
}
)
}
}
}
}
}
@@ -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<Morph, Animatable<Float, *>> {
var fromStep by remember { mutableStateOf(step) }
var toStep by remember { mutableStateOf(step) }
val progress = remember { Animatable(1f) }
LaunchedEffect(step) {
if (step != toStep) {
fromStep = toStep
toStep = step
progress.snapTo(0f)
progress.animateTo(
targetValue = 1f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 380f),
)
}
}
val morph = remember(fromStep, toStep) {
Morph(securityHeroMaterialPolygon(fromStep), securityHeroMaterialPolygon(toStep))
}
return morph to progress
}
/**
* 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 01 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
)
}
}
@@ -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()
)
}
}
@@ -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,
)
@@ -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"
}
File diff suppressed because it is too large Load Diff
@@ -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.
}
}
@@ -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)
}
@@ -0,0 +1,5 @@
package ru.fromchat.ui
import androidx.compose.ui.Modifier
actual fun Modifier.imeScrollWithKeyboard(): Modifier = this
@@ -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)
}