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`
|
`app/android/build/outputs/apk/debug/android-debug.apk`
|
||||||
in this repo.
|
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`.)
|
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.
|
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:
|
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 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
|
# ULTIMATE SILENCE & EFFICIENCY POLICY
|
||||||
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
|
- 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
|
- `GlobalScope.launch { }` for fire-and-forget background operations on iOS
|
||||||
- Platform-specific logging with `ru.fromchat.core.Logger`
|
- 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
|
## Modifier.conditional
|
||||||
- Use `Modifier.conditional` when you need to apply different modifiers based on a condition.
|
- 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.
|
- 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
|
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="dark">Тёмное</string>
|
||||||
<string name="debug_tools">Отладка API</string>
|
<string name="debug_tools">Отладка API</string>
|
||||||
<string name="debug_tools_d">Просмотр ответов 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_unexpected">Что-то пошло не так</string>
|
||||||
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
|
<string name="error_invalid_credentials">Неверное имя пользователя или пароль</string>
|
||||||
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
|
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
|
||||||
|
|||||||
@@ -146,6 +146,76 @@
|
|||||||
<string name="debug_tools">Debug API</string>
|
<string name="debug_tools">Debug API</string>
|
||||||
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</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 -->
|
<!-- Error Messages -->
|
||||||
<string name="error_unexpected">Something went wrong</string>
|
<string name="error_unexpected">Something went wrong</string>
|
||||||
<string name="error_invalid_credentials">Wrong username or password</string>
|
<string name="error_invalid_credentials">Wrong username or password</string>
|
||||||
|
|||||||
@@ -445,21 +445,89 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun listDevices(): List<DeviceSessionInfo> =
|
||||||
runCatching {
|
http
|
||||||
http.get("${Config.apiBaseUrl}/logout")
|
.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")
|
secureSettings.remove("auth_token")
|
||||||
settings.remove("user_info")
|
settings.remove("user_info")
|
||||||
settings.remove("current_user_id")
|
settings.remove("current_user_id")
|
||||||
token = null
|
token = null
|
||||||
user = null
|
user = null
|
||||||
|
uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) }
|
||||||
|
UpdateSyncManager.resetInMemoryOnLogout()
|
||||||
|
runCatching { IdentityKeyManager.clearLocalKeys() }
|
||||||
runCatching { ProfileCache.clear() }
|
runCatching { ProfileCache.clear() }
|
||||||
runCatching { DmPanelCache.clearAll() }
|
runCatching { DmPanelCache.clearAll() }
|
||||||
runCatching { PublicChatPanelCache.clear() }
|
runCatching { PublicChatPanelCache.clear() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun logout() {
|
||||||
|
runCatching {
|
||||||
|
http.get("${Config.apiBaseUrl}/logout")
|
||||||
|
}
|
||||||
|
clearLocalSession()
|
||||||
|
}
|
||||||
|
|
||||||
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
||||||
|
|
||||||
// WebSocket send helpers
|
// WebSocket send helpers
|
||||||
|
|||||||
@@ -79,6 +79,41 @@ data class LoginResponse(
|
|||||||
val token: String
|
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
|
@Serializable
|
||||||
data class MessagesResponse(
|
data class MessagesResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ object UpdateSyncManager {
|
|||||||
ConnectionStateStore.updateSeqAndMissed(lastSeq = _lastSeq.value, missedCount = missedCount)
|
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.
|
* 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
|
* This call is idempotent while in progress and will no-op if there is no active
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package ru.fromchat.core
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.fromchat.api.DeviceSessionInfo
|
||||||
import ru.fromchat.ui.Theme
|
import ru.fromchat.ui.Theme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,8 +24,10 @@ object Settings {
|
|||||||
private const val THEME_KEY = "theme"
|
private const val THEME_KEY = "theme"
|
||||||
private const val SERVER_URL_KEY = "server_url"
|
private const val SERVER_URL_KEY = "server_url"
|
||||||
private const val HTTPS_ENABLED_KEY = "https_enabled"
|
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) {
|
private fun runIO(block: suspend CoroutineScope.() -> Unit) {
|
||||||
CoroutineScope(Dispatchers.IO).launch(block = block)
|
CoroutineScope(Dispatchers.IO).launch(block = block)
|
||||||
@@ -78,5 +83,31 @@ object Settings {
|
|||||||
serverUrl = value.serverUrl
|
serverUrl = value.serverUrl
|
||||||
httpsEnabled = value.httpsEnabled
|
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)
|
* 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.setup.ServerConfigScreen
|
||||||
import ru.fromchat.ui.LocalSystemBarsVisibility
|
import ru.fromchat.ui.LocalSystemBarsVisibility
|
||||||
import ru.fromchat.ui.rememberSystemBarsController
|
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") }
|
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
|
@Composable
|
||||||
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
||||||
setSingletonImageLoaderFactory { context ->
|
setSingletonImageLoaderFactory { context ->
|
||||||
@@ -199,13 +236,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
MainScreen(
|
MainScreen()
|
||||||
onLogout = {
|
|
||||||
navController.navigate("login") {
|
|
||||||
popUpTo("chat") { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("chats/publicChat") {
|
composable("chats/publicChat") {
|
||||||
@@ -307,9 +338,51 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable("about") {
|
settingsSlideComposable("about", animationSpec) {
|
||||||
AboutScreen()
|
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.*
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.utils.exclude
|
import ru.fromchat.utils.exclude
|
||||||
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
|
||||||
private const val PAGE_CHATS = 0
|
private const val PAGE_CHATS = 0
|
||||||
@@ -37,7 +38,7 @@ private const val PAGE_COUNT = 4
|
|||||||
|
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(onLogout: () -> Unit = {}) {
|
fun MainScreen() {
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = PAGE_CHATS,
|
initialPage = PAGE_CHATS,
|
||||||
pageCount = { PAGE_COUNT },
|
pageCount = { PAGE_COUNT },
|
||||||
@@ -97,7 +98,7 @@ fun MainScreen(onLogout: () -> Unit = {}) {
|
|||||||
when (page) {
|
when (page) {
|
||||||
PAGE_CHATS -> ChatsTab()
|
PAGE_CHATS -> ChatsTab()
|
||||||
PAGE_CONTACTS -> ContactsTab()
|
PAGE_CONTACTS -> ContactsTab()
|
||||||
PAGE_SETTINGS -> SettingsTab(onLogout = onLogout)
|
PAGE_SETTINGS -> SettingsTab()
|
||||||
PAGE_PROFILE -> {
|
PAGE_PROFILE -> {
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
ProfileScreen(
|
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