mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement new settings
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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()
|
||||
+28
@@ -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 phone’s 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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
+15
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user