mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Add legal docs, redesign profiles and chats tab, fix fullscreen images, add code style
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Read CODE_STYLE.md before writing or refactoring Kotlin/Compose code
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Code style
|
||||
|
||||
Before writing or editing Kotlin / Compose code in this repo:
|
||||
|
||||
1. **Read** [`CODE_STYLE.md`](../../CODE_STYLE.md) at the repository root.
|
||||
2. **Follow** it — write idiomatic, well-structured code from the start; match neighboring files when a rule is ambiguous.
|
||||
3. **Do not ask the user style questions** when implementing new code.
|
||||
|
||||
For style cleanup on an existing diff or file set, use the **`adapt-to-style`** skill.
|
||||
|
||||
Build and platform rules remain in `android.mdc`.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
description: Keep Kotlin code clean — inline single-use helpers, match project conventions
|
||||
globs: app/shared/**/*.kt,utils/shared/**/*.kt
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Kotlin clean code
|
||||
|
||||
- If a function, variable, or small wrapper is used **once**, inline it at the call site unless it clarifies a non-obvious boundary (network call, animation controller, crypto).
|
||||
- Prefer existing project components (`ActionButton`, `Category`, `ExpressiveStepFlow`, `apiRequest`) over new abstractions.
|
||||
- Match surrounding naming, imports, and composable structure; read adjacent files before adding helpers.
|
||||
- Extract only when shared by **2+** call sites or when the block exceeds ~40 lines of non-trivial logic (pager math, crypto, API wiring).
|
||||
- No hardcoded user-visible strings in shared UI — use Compose Multiplatform resources (`composeResources/values/strings.xml` + `values-ru`).
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: adapt-to-style
|
||||
description: Adapts Kotlin/Compose code (diff, single file, or multiple files) to FromChat CODE_STYLE.md strictly without changing behavior. Use when cleaning up style, refactoring for conventions, adapting a diff to code style, or when the user mentions adapt-to-style, code style cleanup, or style pass.
|
||||
---
|
||||
|
||||
# Adapt to style
|
||||
|
||||
Refactor target code to match [`CODE_STYLE.md`](../../../CODE_STYLE.md) at the repository root. **Do not change behavior.**
|
||||
|
||||
## Writing new code
|
||||
|
||||
When implementing features (not a style-only pass):
|
||||
|
||||
1. Read `CODE_STYLE.md` before writing.
|
||||
2. Follow it from the start — inline single-use bindings, idiomatic Kotlin, match neighboring files.
|
||||
3. **Do not ask the user style questions** — apply the guide and use your judgment.
|
||||
|
||||
## Style adaptation pass
|
||||
|
||||
When cleaning up an existing diff or file set:
|
||||
|
||||
### Before you start
|
||||
|
||||
1. Read `CODE_STYLE.md` fully.
|
||||
2. Identify scope: git diff, named files, or a directory.
|
||||
3. Read surrounding files in the same package for precedent.
|
||||
|
||||
### Refactor checklist
|
||||
|
||||
Apply in order:
|
||||
|
||||
- [ ] **Inline** `val`/`var`/locals/`@Composable` used exactly once in the file (§1).
|
||||
- [ ] **Merge** screen-only helper files into their parent screen file (§2).
|
||||
- [ ] **Replace** non-idiomatic Kotlin with idioms: `runCatching {}`, `buildList {}`, `buildMap {}`, etc. (§3).
|
||||
- [ ] **Group** related multi-file features into sub-packages where appropriate (§4).
|
||||
- [ ] **Reuse** existing project components; remove one-off wrappers (§5).
|
||||
- [ ] **Strings** — no new hardcoded user-visible copy; use compose resources (§6).
|
||||
- [ ] **Formatting** — one blank line between composables; `private` screen helpers; no `.dp` named constants (§7).
|
||||
- [ ] **Expression bodies** where §8 applies.
|
||||
|
||||
### Uncertainty log
|
||||
|
||||
Only during a **style adaptation pass** — not when writing new code.
|
||||
|
||||
When unsure how to refactor something:
|
||||
|
||||
1. Create or append to `.cursor/code_style_progress_<YYYYMMDD-HHmm>.md` (use current local time).
|
||||
2. For each item:
|
||||
|
||||
```markdown
|
||||
## relative/path/File.kt
|
||||
- Unsure: [specific construct and why]
|
||||
- Chosen approach: [what you did for now]
|
||||
```
|
||||
|
||||
3. Continue refactoring — do not block on open questions.
|
||||
4. After all files are done, **re-read** the progress file and ask the user the listed questions.
|
||||
|
||||
### Constraints
|
||||
|
||||
- No behavior, API, or logic changes.
|
||||
- No magic-string sanitization of real user/message data.
|
||||
- Minimal diff: only what style requires.
|
||||
- Do not extract new abstractions that would be used once.
|
||||
- Do not split files that §2 says should be merged.
|
||||
- Do not change public API for style-only passes.
|
||||
|
||||
### Validation
|
||||
|
||||
After Android-affecting changes, run per `android.mdc`:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" && ./gradlew :app:shared:compileAndroidMain :app:shared:compileKotlinIosArm64
|
||||
```
|
||||
|
||||
Fix compile errors before finishing.
|
||||
|
||||
### Output
|
||||
|
||||
Summarize:
|
||||
|
||||
- Files touched and main style changes.
|
||||
- Any entries from the progress file that need user decisions.
|
||||
- Build result.
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
# FromChat Android — Code Style
|
||||
|
||||
Canonical style reference for Kotlin / Compose Multiplatform code in this repository.
|
||||
|
||||
Contributors are **not required** to follow this guide — but sticking to it saves me cleanup time, so it’s appreciated when you do.
|
||||
|
||||
This file is created mostly for AI agents to write good and readable code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inline single-use bindings
|
||||
|
||||
If a `val`, `var`, local function, or `@Composable` is referenced **exactly once** in the file, inline it at the use site.
|
||||
|
||||
Do **not** introduce a named binding only used once.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — used once
|
||||
val padding = MaterialTheme.spacing.medium
|
||||
Box(modifier = Modifier.padding(padding))
|
||||
|
||||
// ✅ GOOD
|
||||
Box(modifier = Modifier.padding(MaterialTheme.spacing.medium))
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — composable used once
|
||||
@Composable
|
||||
private fun ProfileHeaderTitle(text: String) {
|
||||
Text(text = text, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen() {
|
||||
ProfileHeaderTitle(text = title)
|
||||
}
|
||||
|
||||
// ✅ GOOD — inline at the single call site
|
||||
@Composable
|
||||
fun ProfileScreen() {
|
||||
Text(text = title, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
```
|
||||
|
||||
**Keep a name when:**
|
||||
|
||||
- The expression has side effects and must not run twice.
|
||||
- Inlining hides a non-obvious boundary (crypto, network, animation controller, pager math).
|
||||
- Inlining hurts scanability (long chain, non-obvious subexpression).
|
||||
|
||||
---
|
||||
|
||||
## 2. Merge small screen helpers into the main file
|
||||
|
||||
Screen-local helpers that exist only to serve one screen should live in that screen's file, not a separate file.
|
||||
|
||||
Merge into the parent screen file when **all** are true:
|
||||
|
||||
- Used only by that screen (or its direct private helpers in the same file).
|
||||
- Not shared across features or modules.
|
||||
- The combined file stays readable after the merge.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD — ProfileActionButtonRow.kt used only from ProfileScreen.kt
|
||||
// ✅ GOOD — private composables at the bottom of ProfileScreen.kt
|
||||
```
|
||||
|
||||
Extract to a separate file only when shared by **two or more** screens/features, or when the screen file would become unwieldy even after inlining.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kotlin idioms
|
||||
|
||||
Prefer standard library helpers over verbose Java-style patterns.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD
|
||||
try {
|
||||
cache.evict(key)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
// ✅ GOOD
|
||||
runCatching { cache.evict(key) }
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD
|
||||
val items = mutableListOf<Item>()
|
||||
items.add(header)
|
||||
for (row in rows) items.add(row)
|
||||
items.add(footer)
|
||||
|
||||
// ✅ GOOD
|
||||
val items = buildList {
|
||||
add(header)
|
||||
addAll(rows)
|
||||
add(footer)
|
||||
}
|
||||
```
|
||||
|
||||
Use `buildList`, `buildMap`, `buildSet`, `apply`, `also`, `takeIf`, `takeUnless`, scoped functions, and expression bodies where they match surrounding code.
|
||||
|
||||
---
|
||||
|
||||
## 4. Packages for related files
|
||||
|
||||
When several files belong to one feature, group them in a **package directory** instead of scattering at the parent level.
|
||||
|
||||
```
|
||||
// ❌ BAD
|
||||
ui/profile/ProfileScreen.kt
|
||||
ui/profile/ProfileRoutes.kt
|
||||
ui/profile/ProfileBioMarkdown.kt
|
||||
ui/profile/EditProfileScreen.kt // edit is a sub-flow
|
||||
|
||||
// ✅ GOOD
|
||||
ui/profile/ProfileScreen.kt
|
||||
ui/profile/ProfileRoutes.kt
|
||||
ui/profile/bio/ProfileBioMarkdown.kt
|
||||
ui/profile/edit/EditProfileScreen.kt
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- One primary type per file; file name matches the primary type.
|
||||
- Sub-packages for sub-features (e.g. `edit`, `bio`, `panels/dm`).
|
||||
- Do not create a package for a single tiny file that only exists to be merged per §2.
|
||||
|
||||
---
|
||||
|
||||
## 5. Reuse project abstractions
|
||||
|
||||
Prefer existing project components and utilities over new wrappers:
|
||||
|
||||
- `com.pr0gramm3r101.utils` — clipboard, `Modifier.conditional`, etc.
|
||||
- `com.pr0gramm3r101.components` — `Category`, `ListItem`, etc.
|
||||
- `ru.fromchat.ui.components` — shared UI primitives.
|
||||
- `apiRequest` / existing API client patterns.
|
||||
|
||||
Match naming, imports, and structure of adjacent files in the same package.
|
||||
|
||||
---
|
||||
|
||||
## 6. User-visible strings
|
||||
|
||||
No hardcoded user-visible copy in shared UI. Use Compose Multiplatform resources:
|
||||
|
||||
- `app/shared/src/commonMain/composeResources/values/strings.xml`
|
||||
- `app/shared/src/commonMain/composeResources/values-ru/strings.xml`
|
||||
|
||||
Exception: debug API screen (`ru.fromchat.ui.debug`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Compose layout and formatting
|
||||
|
||||
### Blank lines between composables
|
||||
|
||||
Separate **every** `@Composable` in a file with **one** blank line — top-level and `private`.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Header() { ... }
|
||||
|
||||
@Composable
|
||||
fun Body() { ... }
|
||||
```
|
||||
|
||||
### File size
|
||||
|
||||
No hard line limit. Merge or split based on readability.
|
||||
|
||||
### Visibility
|
||||
|
||||
Screen-local composables merged into a screen file are `private`.
|
||||
|
||||
### Layout / dimension constants
|
||||
|
||||
Do **not** introduce named constants for bare `.dp` values — use literals inline.
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD
|
||||
private val CardPadding = 16.dp
|
||||
Box(modifier = Modifier.padding(CardPadding))
|
||||
|
||||
// ✅ GOOD
|
||||
Box(modifier = Modifier.padding(16.dp))
|
||||
```
|
||||
|
||||
For non-trivial layout values (ratios, spring specs, derived calculations), use top-level `private const` or `private val` in the same file.
|
||||
|
||||
---
|
||||
|
||||
## 8. Function bodies
|
||||
|
||||
- If a function contains **only** a `return` statement, always use an expression body (`=`).
|
||||
- If the logic is a progressive data transform chainable with `let` / `apply` / `also` / `run`, prefer an expression body.
|
||||
- Otherwise use a block body.
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD — single return
|
||||
private fun label(user: User) = user.visibleUsername ?: stringResource(Res.string.user_fallback)
|
||||
|
||||
// ✅ GOOD — chain
|
||||
private fun normalized(input: String) = input.trim().takeIf { it.isNotEmpty() }?.lowercase().orEmpty()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. General principles
|
||||
|
||||
- Do not strip or rewrite data by comparing to hard-coded UI placeholder strings.
|
||||
- Do not introduce abstractions used only once (same rule as §1).
|
||||
- When a convention is ambiguous, match neighboring files in the same package.
|
||||
|
||||
@@ -150,4 +150,9 @@ dependencies {
|
||||
|
||||
implementation(project(":app:shared"))
|
||||
implementation(project(":utils:shared"))
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation(libs.androidx.compose.material3)
|
||||
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
|
||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||
}
|
||||
@@ -19,9 +19,9 @@ kotlin {
|
||||
}
|
||||
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
iosSimulatorArm64(),
|
||||
iosX64(),
|
||||
).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
@@ -84,6 +84,7 @@ kotlin {
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.bouncycastle.bcprov)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
|
||||
+9
@@ -10,3 +10,12 @@ actual suspend fun wipeFromChatCacheDirectory() {
|
||||
File(UtilsLibrary.context.cacheDir, "fromchat").deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun wipeAttachmentCacheDirectories() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val cacheDir = UtilsLibrary.context.cacheDir
|
||||
listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name ->
|
||||
File(cacheDir, name).deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class OutboxSendWorker(
|
||||
} finally {
|
||||
progressJob?.cancel()
|
||||
}
|
||||
Result.success()
|
||||
if (allOk) Result.success() else Result.retry()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
|
||||
@Composable
|
||||
actual fun Markdown(
|
||||
content: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
MarkdownPlain(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
onLinkClick = { uriHandler.openUri(it) },
|
||||
)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
|
||||
HapticFeedbackEvent.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK
|
||||
HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM
|
||||
HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM
|
||||
HapticFeedbackEvent.SelectionModeEntered.ordinal -> HapticFeedbackConstants.CONFIRM
|
||||
else -> HapticFeedbackConstants.CLOCK_TICK
|
||||
}
|
||||
view.performHapticFeedback(constant)
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
<string name="about_link_telegram">Telegram</string>
|
||||
<string name="about_link_max">MAX</string>
|
||||
<string name="about_link_website">Сайт</string>
|
||||
<string name="about_link_privacy">Политика конфиденциальности</string>
|
||||
<string name="about_link_terms">Пользовательское соглашение</string>
|
||||
<string name="legal_privacy_title">Политика конфиденциальности</string>
|
||||
<string name="legal_terms_title">Пользовательское соглашение</string>
|
||||
<string name="legal_document_cached_banner">Показана сохранённая копия документа. Содержимое может быть устаревшим.</string>
|
||||
<string name="legal_document_load_error">Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.</string>
|
||||
<string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string>
|
||||
<string name="auth_legal_notice_and"> и </string>
|
||||
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
|
||||
<string name="welcome">Добро пожаловать!</string>
|
||||
<string name="login">Войти</string>
|
||||
@@ -52,6 +60,17 @@
|
||||
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
||||
<string name="public_chat">Общий чат</string>
|
||||
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
||||
<string name="chat_preview_attachment">Вложение</string>
|
||||
<string name="action_mark_read">Прочитано</string>
|
||||
<string name="action_select">Выбрать</string>
|
||||
<string name="action_archive">В архив</string>
|
||||
<string name="chats_selected_count">Выбрано чатов: %1$d</string>
|
||||
<string name="chat_delete_confirm_title">Удалить чаты?</string>
|
||||
<string name="chat_delete_confirm_body">Сообщения в выбранных чатах будут удалены с устройства и сервера.</string>
|
||||
<string name="chat_delete_partial_failure">Не удалось удалить чатов: %1$d</string>
|
||||
<string name="cd_close_selection">Закрыть выбор</string>
|
||||
<string name="cd_chat_selected">Выбрано</string>
|
||||
<string name="cd_selection_more">Ещё действия</string>
|
||||
<string name="search_hint">Найдите пользователя, имя или чат</string>
|
||||
<string name="search_title">Поиск</string>
|
||||
<string name="search_not_found">Ничего не найдено</string>
|
||||
@@ -105,6 +124,9 @@
|
||||
<string name="action_delete">Удалить</string>
|
||||
<string name="action_copy">Копировать</string>
|
||||
<string name="action_cancel_send">Отменить</string>
|
||||
<string name="action_retry_send">Повторить</string>
|
||||
<string name="message_send_failed">Не удалось отправить</string>
|
||||
<string name="cd_message_send_failed">Сообщение не отправлено</string>
|
||||
<string name="notif_media_upload_percent">%1$d\u0025</string>
|
||||
<string name="notif_media_upload_progress">%1$s · %2$s</string>
|
||||
<string name="notif_file_copy_channel_name">Сохранение файла</string>
|
||||
@@ -123,12 +145,24 @@
|
||||
<string name="cd_send">Отправить</string>
|
||||
<string name="cd_emoji">Эмодзи</string>
|
||||
<string name="profile_title">Профиль</string>
|
||||
<string name="profile_edit_title">Изменить</string>
|
||||
<string name="profile_edit_saved">Профиль обновлён</string>
|
||||
<string name="profile_bio_length_error">Не более %1$d символов</string>
|
||||
<string name="profile_registration_date">%1$d %2$s %3$d</string>
|
||||
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
||||
<string name="profile_not_found">Профиль не найден</string>
|
||||
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
||||
<string name="action_open_settings">Настройки</string>
|
||||
<string name="action_chat">Написать</string>
|
||||
<string name="action_copy_link">Скопировать ссылку</string>
|
||||
<string name="profile_action_chat">Написать</string>
|
||||
<string name="profile_action_link">Ссылка</string>
|
||||
<string name="profile_action_settings">Настройки</string>
|
||||
<string name="profile_action_call">Позвонить</string>
|
||||
<string name="profile_action_video">Видео</string>
|
||||
<string name="profile_action_contact_info">Контактная информация</string>
|
||||
<string name="profile_action_search">Поиск</string>
|
||||
<string name="feature_not_implemented">Пока не реализовано</string>
|
||||
<string name="link_copied">Ссылка скопирована</string>
|
||||
<string name="profile_details_category">О человеке</string>
|
||||
<string name="profile_headline_username">Имя пользователя</string>
|
||||
@@ -167,6 +201,18 @@
|
||||
<string name="month_oct">окт</string>
|
||||
<string name="month_nov">ноя</string>
|
||||
<string name="month_dec">дек</string>
|
||||
<string name="month_name_jan">января</string>
|
||||
<string name="month_name_feb">февраля</string>
|
||||
<string name="month_name_mar">марта</string>
|
||||
<string name="month_name_apr">апреля</string>
|
||||
<string name="month_name_may">мая</string>
|
||||
<string name="month_name_jun">июня</string>
|
||||
<string name="month_name_jul">июля</string>
|
||||
<string name="month_name_aug">августа</string>
|
||||
<string name="month_name_sep">сентября</string>
|
||||
<string name="month_name_oct">октября</string>
|
||||
<string name="month_name_nov">ноября</string>
|
||||
<string name="month_name_dec">декабря</string>
|
||||
<string name="server_config_title">Настройка сервера</string>
|
||||
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
|
||||
<string name="server_ip_label">IP или имя сервера</string>
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
<string name="about_link_telegram">Telegram</string>
|
||||
<string name="about_link_max">MAX</string>
|
||||
<string name="about_link_website">Website</string>
|
||||
<string name="about_link_privacy">Privacy policy</string>
|
||||
<string name="about_link_terms">Terms of service</string>
|
||||
<string name="legal_privacy_title">Privacy policy</string>
|
||||
<string name="legal_terms_title">Terms of service</string>
|
||||
<string name="legal_document_cached_banner">Showing a saved copy. Content may be out of date.</string>
|
||||
<string name="legal_document_load_error">Couldn\'t load the document. Check your connection and try again.</string>
|
||||
<string name="auth_legal_notice_prefix">By creating an account you agree to the </string>
|
||||
<string name="auth_legal_notice_and"> and </string>
|
||||
<string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
|
||||
|
||||
<!-- Authentication -->
|
||||
@@ -60,6 +68,17 @@
|
||||
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
|
||||
<string name="public_chat">Main chat</string>
|
||||
<string name="chat_last_mesaage">You: last message</string>
|
||||
<string name="chat_preview_attachment">Attachment</string>
|
||||
<string name="action_mark_read">Mark as read</string>
|
||||
<string name="action_select">Select</string>
|
||||
<string name="action_archive">Archive</string>
|
||||
<string name="chats_selected_count">%1$d chats selected</string>
|
||||
<string name="chat_delete_confirm_title">Delete chats?</string>
|
||||
<string name="chat_delete_confirm_body">Messages in selected chats will be deleted from this device and the server.</string>
|
||||
<string name="chat_delete_partial_failure">Could not delete %1$d chat(s)</string>
|
||||
<string name="cd_close_selection">Close selection</string>
|
||||
<string name="cd_chat_selected">Selected</string>
|
||||
<string name="cd_selection_more">More actions</string>
|
||||
<string name="search_hint">Search by name, username or chat</string>
|
||||
<string name="search_title">Search</string>
|
||||
<string name="search_not_found">No results</string>
|
||||
@@ -119,6 +138,9 @@
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="action_copy">Copy</string>
|
||||
<string name="action_cancel_send">Cancel</string>
|
||||
<string name="action_retry_send">Retry</string>
|
||||
<string name="message_send_failed">Couldn\'t send</string>
|
||||
<string name="cd_message_send_failed">Message failed to send</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="notif_media_upload_percent">%1$d\u0025</string>
|
||||
<string name="notif_media_upload_progress">%1$s · %2$s</string>
|
||||
@@ -141,12 +163,24 @@
|
||||
|
||||
<!-- Profile -->
|
||||
<string name="profile_title">Profile</string>
|
||||
<string name="profile_edit_title">Edit</string>
|
||||
<string name="profile_edit_saved">Profile updated</string>
|
||||
<string name="profile_bio_length_error">Up to %1$d characters</string>
|
||||
<string name="profile_registration_date">%1$d %2$s %3$d</string>
|
||||
<string name="profile_load_failed">Couldn’t load this profile</string>
|
||||
<string name="profile_not_found">This profile could not be found</string>
|
||||
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
|
||||
<string name="action_open_settings">Settings</string>
|
||||
<string name="action_chat">Chat</string>
|
||||
<string name="action_copy_link">Copy link</string>
|
||||
<string name="profile_action_chat">Chat</string>
|
||||
<string name="profile_action_link">Link</string>
|
||||
<string name="profile_action_settings">Settings</string>
|
||||
<string name="profile_action_call">Call</string>
|
||||
<string name="profile_action_video">Video</string>
|
||||
<string name="profile_action_contact_info">Contact info</string>
|
||||
<string name="profile_action_search">Search</string>
|
||||
<string name="feature_not_implemented">Not implemented yet</string>
|
||||
<string name="link_copied">Link copied</string>
|
||||
<string name="profile_details_category">About this person</string>
|
||||
<string name="profile_headline_username">Username</string>
|
||||
@@ -191,6 +225,18 @@
|
||||
<string name="month_oct">Oct</string>
|
||||
<string name="month_nov">Nov</string>
|
||||
<string name="month_dec">Dec</string>
|
||||
<string name="month_name_jan">january</string>
|
||||
<string name="month_name_feb">february</string>
|
||||
<string name="month_name_mar">march</string>
|
||||
<string name="month_name_apr">april</string>
|
||||
<string name="month_name_may">may</string>
|
||||
<string name="month_name_jun">june</string>
|
||||
<string name="month_name_jul">july</string>
|
||||
<string name="month_name_aug">august</string>
|
||||
<string name="month_name_sep">september</string>
|
||||
<string name="month_name_oct">october</string>
|
||||
<string name="month_name_nov">november</string>
|
||||
<string name="month_name_dec">december</string>
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<string name="server_config_title">Connect to a server</string>
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.forms.submitFormWithBinaryData
|
||||
import io.ktor.client.plugins.HttpResponseValidator
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
@@ -29,7 +31,6 @@ import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.encodedPath
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -46,18 +47,21 @@ import ru.fromchat.api.crypto.transport.TransportCrypto
|
||||
import ru.fromchat.api.instance.InstanceIdGuard
|
||||
import ru.fromchat.api.instance.InstanceIdResolveResult
|
||||
import ru.fromchat.api.instance.resolveInstanceId
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.send.cancelOutboxProcessing
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.cache.readOutboundFileBytes
|
||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.download.streamEncryptedFileToDisk
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl
|
||||
import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload
|
||||
import ru.fromchat.api.schema.calls.LiveKitTokenRequest
|
||||
import ru.fromchat.api.schema.calls.LiveKitTokenResponse
|
||||
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
||||
import ru.fromchat.api.schema.messages.MarkReadRequest
|
||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||
import ru.fromchat.api.schema.messages.dm.DmConversation
|
||||
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
|
||||
@@ -71,15 +75,17 @@ import ru.fromchat.api.schema.messages.dm.upload.DmUploadCompleteResponse
|
||||
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitRequest
|
||||
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse
|
||||
import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse
|
||||
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
|
||||
import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest
|
||||
import ru.fromchat.api.schema.server.RegisteredUserCountResponse
|
||||
import ru.fromchat.api.schema.server.ServerInstanceIdResponse
|
||||
import ru.fromchat.api.schema.server.TransportKeyResponse
|
||||
import ru.fromchat.api.schema.user.ChangePasswordApiRequest
|
||||
import ru.fromchat.api.schema.user.DeleteAccountRequest
|
||||
import ru.fromchat.api.schema.user.VerifyPasswordRequest
|
||||
import ru.fromchat.api.schema.user.FcmTokenRequest
|
||||
import ru.fromchat.api.schema.user.User
|
||||
import ru.fromchat.api.schema.user.UsersSearchResponse
|
||||
import ru.fromchat.api.schema.user.VerifyPasswordRequest
|
||||
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
||||
@@ -91,6 +97,8 @@ import ru.fromchat.api.schema.user.keys.BackupBlobRequest
|
||||
import ru.fromchat.api.schema.user.keys.BackupBlobResponse
|
||||
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
|
||||
import ru.fromchat.api.schema.user.profile.SimilarityResult
|
||||
import ru.fromchat.api.schema.user.profile.UpdateProfileRequest
|
||||
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
|
||||
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||
import ru.fromchat.api.schema.user.profile.VerifyResponse
|
||||
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
||||
@@ -296,13 +304,11 @@ object ApiClient {
|
||||
suspend fun probeHttpGet(url: String): Boolean =
|
||||
runCatching {
|
||||
httpProbe.get(url.trim())
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}.isSuccess
|
||||
|
||||
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean =
|
||||
runCatching {
|
||||
val base = apiBaseUrl.trimEnd('/')
|
||||
httpProbe.get("$base/check_auth") {
|
||||
httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") {
|
||||
bearerAuth(bearer)
|
||||
}.body<CheckAuthResponse>().authenticated
|
||||
}.getOrDefault(false)
|
||||
@@ -455,6 +461,11 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun persistCurrentUser() {
|
||||
val currentUser = user ?: return
|
||||
settings.putString("user_info", json.encodeToString(currentUser))
|
||||
}
|
||||
|
||||
// --- User & profile ---
|
||||
|
||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||
@@ -466,6 +477,21 @@ object ApiClient {
|
||||
}
|
||||
.body<MessagesResponse>()
|
||||
|
||||
suspend fun getNewMessages(): MessagesResponse =
|
||||
http
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new") {
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
.body()
|
||||
|
||||
suspend fun markMessagesRead(messageIds: List<Int>) {
|
||||
if (messageIds.isEmpty()) return
|
||||
http.post("${ServerConfig.apiBaseUrl}/messages/read") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(MarkReadRequest(messageIds = messageIds))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getOwnProfile(): UserProfile =
|
||||
http
|
||||
.get("${ServerConfig.apiBaseUrl}/user/profile") {
|
||||
@@ -487,6 +513,54 @@ object ApiClient {
|
||||
}
|
||||
.body()
|
||||
|
||||
suspend fun updateProfile(
|
||||
username: String? = null,
|
||||
displayName: String? = null,
|
||||
bio: String? = null,
|
||||
): UpdateProfileResponse {
|
||||
val response = http
|
||||
.put("${ServerConfig.apiBaseUrl}/user/profile") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
UpdateProfileRequest(
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
description = bio,
|
||||
)
|
||||
)
|
||||
}
|
||||
.body<UpdateProfileResponse>()
|
||||
|
||||
val currentUser = user
|
||||
if (currentUser != null) {
|
||||
user = currentUser.copy(
|
||||
username = response.username,
|
||||
displayName = response.displayName,
|
||||
bio = response.bio,
|
||||
)
|
||||
persistCurrentUser()
|
||||
}
|
||||
|
||||
ProfileCache.get(currentUser?.id ?: 0)?.let { cached ->
|
||||
ProfileCache.put(
|
||||
cached.copy(
|
||||
username = response.username,
|
||||
displayName = response.displayName,
|
||||
bio = response.bio,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun getPublicChatProfile(): PublicChatProfile =
|
||||
http
|
||||
.get("${ServerConfig.apiBaseUrl}/public-chat/profile") {
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
.body()
|
||||
|
||||
suspend fun getRegisteredUserCount(): Int =
|
||||
http
|
||||
.get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") {
|
||||
@@ -521,6 +595,18 @@ object ApiClient {
|
||||
.body<DmConversationsResponse>()
|
||||
.conversations
|
||||
|
||||
suspend fun searchUsers(query: String): List<User> {
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.length < 2) return emptyList()
|
||||
return http
|
||||
.get("${ServerConfig.apiBaseUrl}/users/search") {
|
||||
contentType(ContentType.Application.Json)
|
||||
parameter("q", trimmed)
|
||||
}
|
||||
.body<UsersSearchResponse>()
|
||||
.users
|
||||
}
|
||||
|
||||
suspend fun getDmFetch(since: Int? = null): DmHistoryResponse {
|
||||
return http
|
||||
.get("${ServerConfig.apiBaseUrl}/dm/fetch") {
|
||||
@@ -1263,6 +1349,11 @@ object ApiClient {
|
||||
* or together with [logout] after remote logout).
|
||||
*/
|
||||
suspend fun clearLocalSession() {
|
||||
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
|
||||
if (instanceId.isNotEmpty()) {
|
||||
runCatching { cancelOutboxProcessing(instanceId) }
|
||||
runCatching { MessageRepository.purgeAllPendingForInstance() }
|
||||
}
|
||||
val uid = user?.id
|
||||
secureSettings.remove("auth_token")
|
||||
settings.remove("user_info")
|
||||
@@ -1277,7 +1368,9 @@ object ApiClient {
|
||||
runCatching { IdentityKeyManager.clearLocalKeys() }
|
||||
runCatching { ProfileCache.clear() }
|
||||
runCatching { DmPanelCache.clearAll() }
|
||||
runCatching { PublicChatProfileCache.clear() }
|
||||
runCatching { PublicChatPanelCache.clear() }
|
||||
runCatching { CacheContext.clearActive() }
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
@@ -1290,10 +1383,15 @@ object ApiClient {
|
||||
|
||||
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
|
||||
if (_suspensionState.value.isSuspended) return
|
||||
http.post("${ServerConfig.apiBaseUrl}/send_message") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SendMessageRequest(content = content, reply_to_id = replyToId))
|
||||
}
|
||||
val payloadJson = json.encodeToString(
|
||||
SendMessageRequest(content = content.trim(), reply_to_id = replyToId),
|
||||
)
|
||||
http.submitFormWithBinaryData(
|
||||
url = "${ServerConfig.apiBaseUrl}/send_message",
|
||||
formData = formData {
|
||||
append("payload", payloadJson)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// WebSocket send helpers
|
||||
|
||||
@@ -7,6 +7,7 @@ import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||
import ru.fromchat.config.ServerConfigData
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.legal.DocumentRepository
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
sealed interface ServerProbeResult {
|
||||
@@ -64,6 +65,7 @@ suspend fun applyServerConfig(
|
||||
) {
|
||||
val tentative = config.copy(callsEnabled = callsOk)
|
||||
ServerConfig.updateServerConfig(tentative)
|
||||
DocumentRepository.invalidate()
|
||||
val userId = ApiClient.user?.id
|
||||
InstanceRegistryStore.rebindServerInstance(tentative, instanceId)
|
||||
CacheContext.setActiveInstance(instanceId, userId)
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.config.Settings
|
||||
@@ -35,6 +36,11 @@ private fun activateInstance(instanceId: String) {
|
||||
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
|
||||
scheduleOutboxProcessing(instanceId)
|
||||
scheduleAttachmentResumeAfterSession()
|
||||
ApiClient.user?.id?.let { userId ->
|
||||
bootstrapScope.launch {
|
||||
runCatching { MessageRepository.purgePendingNotFromUser(userId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
@@ -162,6 +162,19 @@ object DecryptedFileCache {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun invalidateForMessage(messageId: Int) {
|
||||
if (messageId <= 0) return
|
||||
val dir = ensureCacheDir() ?: return
|
||||
withContext(Dispatchers.Default) {
|
||||
cacheMutex.withLock {
|
||||
memoryCache.keys.removeAll { it.startsWith("file_${messageId}_") }
|
||||
}
|
||||
runCatching {
|
||||
PlatformFileSystem.deleteFilesWithPrefix(dir, "file_${messageId}_")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getOrDecrypt(
|
||||
messageId: Int,
|
||||
fileIndex: Int,
|
||||
|
||||
+11
@@ -1,4 +1,15 @@
|
||||
package ru.fromchat.api.local.cache
|
||||
|
||||
import ru.fromchat.api.local.download.LocalDecodedImageCache
|
||||
|
||||
/** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */
|
||||
expect suspend fun wipeFromChatCacheDirectory()
|
||||
|
||||
/** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */
|
||||
expect suspend fun wipeAttachmentCacheDirectories()
|
||||
|
||||
suspend fun wipeAllOnDiskAttachmentCaches() {
|
||||
wipeFromChatCacheDirectory()
|
||||
wipeAttachmentCacheDirectories()
|
||||
LocalDecodedImageCache.evictPrefix("img_")
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||
import ru.fromchat.api.local.send.cancelOutboxProcessing
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories
|
||||
import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
|
||||
/**
|
||||
* Drops the on-disk FromChat cache tree and reopens SQLite on next access.
|
||||
@@ -22,6 +24,8 @@ suspend fun wipeLocalCacheOnDisk() {
|
||||
MessageDatabaseProvider.closeAndReset()
|
||||
}
|
||||
wipeFromChatCacheDirectory()
|
||||
wipeAttachmentCacheDirectories()
|
||||
PublicChatProfileCache.clear()
|
||||
PublicChatPanelCache.clear()
|
||||
DmPanelCache.clearAll()
|
||||
}
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ internal object MessageDatabaseExpectedSchema {
|
||||
lastMessagePreview TEXT,
|
||||
unreadCount INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (instanceId, id)
|
||||
)
|
||||
""".trimIndent(),
|
||||
@@ -122,6 +123,7 @@ internal object MessageDatabaseExpectedSchema {
|
||||
"lastMessagePreview" to "TEXT",
|
||||
"unreadCount" to "INTEGER NOT NULL DEFAULT 0",
|
||||
"updatedAt" to "TEXT",
|
||||
"archived" to "INTEGER NOT NULL DEFAULT 0",
|
||||
),
|
||||
"message" to mapOf(
|
||||
"instanceId" to "TEXT NOT NULL",
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
object DmConversationListNotifier {
|
||||
private val _events = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
|
||||
val events: SharedFlow<Unit> = _events.asSharedFlow()
|
||||
|
||||
fun notifyChanged() {
|
||||
_events.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
+2
@@ -6,6 +6,7 @@ import ru.fromchat.api.local.send.cancelOutboxProcessing
|
||||
import ru.fromchat.config.ServerConfigData
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.instance.configKey
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
|
||||
import kotlin.time.Clock
|
||||
@@ -68,6 +69,7 @@ object InstanceRegistryStore {
|
||||
}
|
||||
val userId = CacheContext.activeUserId.value
|
||||
CacheContext.setActiveInstance(newId, userId)
|
||||
PublicChatProfileCache.clear()
|
||||
PublicChatPanelCache.clear()
|
||||
DmPanelCache.clearAll()
|
||||
}
|
||||
|
||||
+281
-27
@@ -19,13 +19,17 @@ import ru.fromchat.api.local.db.parseDmMessageContent
|
||||
import ru.fromchat.api.local.db.resolveLocalPreviewUri
|
||||
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
||||
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
|
||||
import ru.fromchat.api.local.send.SEND_ERROR_FAILED
|
||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmConversation
|
||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.cache.CacheValidator
|
||||
import ru.fromchat.db.Conversation
|
||||
import ru.fromchat.db.MessageDatabase
|
||||
import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.api.local.cache.DecryptedFileCache
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.download.DownloadedFileRegistry
|
||||
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
||||
@@ -118,6 +122,7 @@ object MessageCacheStore {
|
||||
purgeSupersededPendingRows(iid, convId, before, merged)
|
||||
}
|
||||
replaceMessages(convId, merged)
|
||||
pruneEmptyConversations()
|
||||
}
|
||||
|
||||
private suspend fun filterStillPendingForReplace(
|
||||
@@ -156,6 +161,34 @@ object MessageCacheStore {
|
||||
upsertSingle(conversationIdForPublic(), message)
|
||||
}
|
||||
|
||||
suspend fun markSendFailed(conversationId: String, clientMessageId: String) {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
|
||||
sendStatus = "failed",
|
||||
instanceId = iid,
|
||||
conversationId = conversationId,
|
||||
clientMessageId = cid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSendFailed(conversationId: String, clientMessageId: String) {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
|
||||
sendStatus = "pending",
|
||||
instanceId = iid,
|
||||
conversationId = conversationId,
|
||||
clientMessageId = cid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
|
||||
upsertSingle(conversationIdForDm(otherUserId), message)
|
||||
syncDmConversationPreviewFromCache(otherUserId)
|
||||
@@ -228,44 +261,257 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceDmConversations(conversations: List<DmConversation>) {
|
||||
suspend fun replaceDmConversations(
|
||||
conversations: List<DmConversation>,
|
||||
attachmentOnlyPreview: String,
|
||||
) {
|
||||
val iid = instanceId()
|
||||
val currentUserId = ApiClient.user?.id
|
||||
withContext(Dispatchers.Default) {
|
||||
val upserts = conversations.map { conv ->
|
||||
val conversationId = conversationIdForDm(conv.user.id)
|
||||
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: conv.user.username.trim()
|
||||
UpsertDmConversationRow(
|
||||
conversationId = conversationId,
|
||||
otherUserId = conv.user.id,
|
||||
displayName = displayLabel,
|
||||
lastMessageId = conv.lastMessage.id,
|
||||
lastMessagePreview = buildDmListPreview(
|
||||
conv.lastMessage,
|
||||
currentUserId,
|
||||
attachmentOnlyPreview,
|
||||
),
|
||||
unreadCount = conv.unreadCount,
|
||||
updatedAt = conv.lastMessage.timestamp,
|
||||
)
|
||||
}
|
||||
db.messageDatabaseQueries.transaction {
|
||||
upserts.forEach { row ->
|
||||
val archived = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, row.conversationId)
|
||||
.executeAsOneOrNull()
|
||||
?.archived ?: 0L
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
instanceId = iid,
|
||||
id = row.conversationId,
|
||||
type = "dm",
|
||||
otherUserId = row.otherUserId.toLong(),
|
||||
displayName = row.displayName,
|
||||
lastMessageId = row.lastMessageId.toLong(),
|
||||
lastMessagePreview = row.lastMessagePreview,
|
||||
unreadCount = row.unreadCount.toLong(),
|
||||
updatedAt = row.updatedAt,
|
||||
archived = archived,
|
||||
)
|
||||
}
|
||||
reconcileDmConversationsLocked(iid, upserts.map { it.otherUserId }.toSet())
|
||||
pruneEmptyConversationsLocked(iid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class UpsertDmConversationRow(
|
||||
val conversationId: String,
|
||||
val otherUserId: Int,
|
||||
val displayName: String,
|
||||
val lastMessageId: Int,
|
||||
val lastMessagePreview: String?,
|
||||
val unreadCount: Int,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
private suspend fun buildDmListPreview(
|
||||
envelope: DmEnvelope,
|
||||
currentUserId: Int?,
|
||||
attachmentOnlyPreview: String,
|
||||
): String? {
|
||||
val hasFiles = !envelope.files.isNullOrEmpty()
|
||||
val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val previewSource = when {
|
||||
decrypted != null -> decrypted
|
||||
hasFiles -> attachmentOnlyPreview
|
||||
else -> null
|
||||
}
|
||||
return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun reconcileDmConversationsLocked(instanceId: String, serverOtherUserIds: Set<Int>) {
|
||||
val localDm = db.messageDatabaseQueries
|
||||
.selectConversationsForInstance(instanceId)
|
||||
.executeAsList()
|
||||
.filter { it.type == "dm" }
|
||||
localDm.forEach { row ->
|
||||
val otherId = row.otherUserId?.toInt() ?: return@forEach
|
||||
if (otherId !in serverOtherUserIds) {
|
||||
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pruneEmptyConversations() {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.transaction {
|
||||
conversations.forEach { conv ->
|
||||
val conversationId = conversationIdForDm(conv.user.id)
|
||||
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: conv.user.username.trim()
|
||||
val recent = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(iid, conversationId, 1)
|
||||
.executeAsList()
|
||||
.firstOrNull()
|
||||
val rawPreview = recent?.content.orEmpty().trim()
|
||||
val preview = rawPreview.takeIf { it.isNotEmpty() }
|
||||
?.let { truncateDmListPreview(it) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
pruneEmptyConversationsLocked(iid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneEmptyConversationsLocked(instanceId: String) {
|
||||
db.messageDatabaseQueries.deleteEmptyDmConversations(instanceId, instanceId)
|
||||
}
|
||||
|
||||
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
val existing = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull()
|
||||
if (existing != null) return@withContext
|
||||
val label = displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: ""
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
instanceId = iid,
|
||||
id = convId,
|
||||
type = "dm",
|
||||
otherUserId = otherUserId.toLong(),
|
||||
displayName = label,
|
||||
lastMessageId = null,
|
||||
lastMessagePreview = null,
|
||||
unreadCount = 0L,
|
||||
updatedAt = null,
|
||||
archived = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markDmConversationRead(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.updateConversationUnreadCount(
|
||||
unreadCount = 0L,
|
||||
instanceId = iid,
|
||||
id = convId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun selectUnreadPublicMessageIds(): List<Int> {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
return withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries
|
||||
.selectUnreadPublicMessageIds(iid, convId)
|
||||
.executeAsList()
|
||||
.map { it.toInt() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markPublicMessagesReadLocally() {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForPublic()
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun archiveDmConversation(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries.updateConversationArchived(
|
||||
archived = 1L,
|
||||
instanceId = iid,
|
||||
id = convId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteDmConversation(otherUserId: Int) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
val messages = db.messageDatabaseQueries
|
||||
.selectMessagesByConversation(iid, convId)
|
||||
.executeAsList()
|
||||
messages.forEach { row ->
|
||||
val msgId = row.id.toInt()
|
||||
DecryptedImageCache.invalidateForMessage(msgId)
|
||||
DecryptedFileCache.invalidateForMessage(msgId)
|
||||
row.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { cid ->
|
||||
DecryptedImageCache.invalidateForClientMessage(cid)
|
||||
DecryptedFileCache.invalidateForClientMessage(cid)
|
||||
}
|
||||
}
|
||||
db.messageDatabaseQueries.transaction {
|
||||
db.messageDatabaseQueries.deleteMessagesForConversation(iid, convId)
|
||||
val outbox = db.messageDatabaseQueries.selectPendingOutboxForInstance(iid).executeAsList()
|
||||
outbox.filter { it.conversationId == convId }.forEach { row ->
|
||||
db.messageDatabaseQueries.deleteOutboxItem(iid, row.clientMessageId)
|
||||
}
|
||||
db.messageDatabaseQueries.deleteConversationById(iid, convId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun purgePendingNotFromUser(userId: Int) {
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
val foreign = db.messageDatabaseQueries
|
||||
.selectForeignPendingMessages(iid, userId.toLong())
|
||||
.executeAsList()
|
||||
foreign.forEach { row ->
|
||||
val cid = row.clientMessageId?.trim().orEmpty()
|
||||
if (cid.isNotEmpty()) {
|
||||
OutgoingMessageCoordinator.cancelOutboundMessage(cid, row.conversationId)
|
||||
} else {
|
||||
db.messageDatabaseQueries.deleteMessageById(
|
||||
instanceId = iid,
|
||||
id = conversationId,
|
||||
type = "dm",
|
||||
otherUserId = conv.user.id.toLong(),
|
||||
displayName = displayLabel,
|
||||
lastMessageId = conv.lastMessage.id.toLong(),
|
||||
lastMessagePreview = preview,
|
||||
unreadCount = conv.unreadCount.toLong(),
|
||||
updatedAt = conv.lastMessage.timestamp
|
||||
conversationId = row.conversationId,
|
||||
id = row.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun purgeAllPendingForInstance() {
|
||||
val iid = runCatching { instanceId() }.getOrNull()?.trim().orEmpty()
|
||||
if (iid.isEmpty()) return
|
||||
withContext(Dispatchers.Default) {
|
||||
val pending = db.messageDatabaseQueries
|
||||
.selectAllPendingMessagesForInstance(iid)
|
||||
.executeAsList()
|
||||
pending.forEach { row ->
|
||||
val cid = row.clientMessageId?.trim().orEmpty()
|
||||
if (cid.isNotEmpty()) {
|
||||
runCatching {
|
||||
OutgoingMessageCoordinator.cancelOutboundMessage(cid, row.conversationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.messageDatabaseQueries.transaction {
|
||||
db.messageDatabaseQueries.deleteAllPendingMessagesForInstance(iid)
|
||||
val outbox = db.messageDatabaseQueries.selectPendingOutboxForInstance(iid).executeAsList()
|
||||
outbox.forEach { row ->
|
||||
db.messageDatabaseQueries.deleteOutboxItem(iid, row.clientMessageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
withContext(Dispatchers.Default) {
|
||||
db.messageDatabaseQueries
|
||||
.selectConversationsForInstance(instanceId())
|
||||
.selectActiveDmConversationsForInstance(instanceId())
|
||||
.executeAsList()
|
||||
.filter { row: Conversation -> row.type == "dm" }
|
||||
.map { row: Conversation ->
|
||||
CachedConversation(
|
||||
id = row.id,
|
||||
@@ -302,7 +548,8 @@ object MessageCacheStore {
|
||||
lastMessageId = row.lastMessageId,
|
||||
lastMessagePreview = preview,
|
||||
unreadCount = row.unreadCount,
|
||||
updatedAt = row.updatedAt
|
||||
updatedAt = row.updatedAt,
|
||||
archived = row.archived,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -374,7 +621,10 @@ object MessageCacheStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
|
||||
dmOtherUserIdFromConversationId(conversationId)?.let {
|
||||
syncDmConversationPreviewFromCache(it)
|
||||
pruneEmptyConversations()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadMessages(conversationId: String): List<Message> {
|
||||
@@ -558,6 +808,7 @@ object MessageCacheStore {
|
||||
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
|
||||
aspectRatioFromDimensionPair(w, h)
|
||||
},
|
||||
uploadError = if (sendStatus == "failed") SEND_ERROR_FAILED else null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -610,7 +861,10 @@ object MessageCacheStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
|
||||
dmOtherUserIdFromConversationId(conversationId)?.let {
|
||||
syncDmConversationPreviewFromCache(it)
|
||||
pruneEmptyConversations()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hasSentMessageWithClientId(conversationId: String, clientMessageId: String): Boolean {
|
||||
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
|
||||
import ru.fromchat.api.local.messages.conversationIdForDm
|
||||
import ru.fromchat.api.local.messages.conversationIdForGroup
|
||||
@@ -63,11 +64,46 @@ object MessageRepository {
|
||||
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
|
||||
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
|
||||
|
||||
suspend fun replaceDmConversations(conversations: List<DmConversation>) =
|
||||
MessageCacheStore.replaceDmConversations(conversations)
|
||||
suspend fun replaceDmConversations(
|
||||
conversations: List<DmConversation>,
|
||||
attachmentOnlyPreview: String,
|
||||
) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview)
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
MessageCacheStore.loadCachedDmConversations()
|
||||
|
||||
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
|
||||
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
|
||||
|
||||
suspend fun markDmConversationRead(otherUserId: Int) =
|
||||
MessageCacheStore.markDmConversationRead(otherUserId)
|
||||
|
||||
suspend fun markPublicConversationRead() {
|
||||
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
|
||||
val serverIds = runCatching {
|
||||
ApiClient.getNewMessages().messages.map { it.id }
|
||||
}.getOrDefault(emptyList())
|
||||
val ids = (localIds + serverIds).distinct()
|
||||
if (ids.isNotEmpty()) {
|
||||
runCatching { ApiClient.markMessagesRead(ids) }
|
||||
}
|
||||
MessageCacheStore.markPublicMessagesReadLocally()
|
||||
}
|
||||
|
||||
suspend fun archiveDmConversation(otherUserId: Int) =
|
||||
MessageCacheStore.archiveDmConversation(otherUserId)
|
||||
|
||||
suspend fun deleteDmConversation(otherUserId: Int) =
|
||||
MessageCacheStore.deleteDmConversation(otherUserId)
|
||||
|
||||
suspend fun purgePendingNotFromUser(userId: Int) =
|
||||
MessageCacheStore.purgePendingNotFromUser(userId)
|
||||
|
||||
suspend fun purgeAllPendingForInstance() =
|
||||
MessageCacheStore.purgeAllPendingForInstance()
|
||||
|
||||
suspend fun pruneEmptyConversations() =
|
||||
MessageCacheStore.pruneEmptyConversations()
|
||||
|
||||
suspend fun clearAllCache() = MessageCacheStore.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,63 @@ object ProfileCache {
|
||||
|
||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||
|
||||
fun findByUsername(username: String): UserProfile? =
|
||||
username.trim().takeIf { it.isNotEmpty() }?.let { needle ->
|
||||
profiles.values.firstOrNull { profile ->
|
||||
profile.username.trim().equals(needle, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges minimal identity (name, avatar) from any UI surface that showed this user.
|
||||
* Skips when a full profile row is already cached.
|
||||
*/
|
||||
fun mergePreview(
|
||||
id: Int,
|
||||
username: String? = null,
|
||||
displayName: String? = null,
|
||||
profilePicture: String? = null,
|
||||
) {
|
||||
if (id <= 0) return
|
||||
val existing = get(id)
|
||||
if (existing != null && !existing.isClientPreviewOnly) return
|
||||
|
||||
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: incomingUsername
|
||||
|
||||
if (incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
|
||||
|
||||
put(
|
||||
UserProfile(
|
||||
id = id,
|
||||
username = incomingUsername.orEmpty(),
|
||||
displayName = incomingDisplayName,
|
||||
profilePicture = profilePicture?.takeIf { it.isNotBlank() }
|
||||
?: existing?.profilePicture,
|
||||
bio = existing?.bio,
|
||||
online = existing?.online ?: false,
|
||||
lastSeen = existing?.lastSeen,
|
||||
createdAt = existing?.createdAt,
|
||||
verified = existing?.verified,
|
||||
suspended = existing?.suspended,
|
||||
suspensionReason = existing?.suspensionReason,
|
||||
deleted = existing?.deleted,
|
||||
isClientPreviewOnly = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeFromCachedConversation(conversation: CachedConversation) {
|
||||
if (conversation.otherUserId <= 0) return
|
||||
mergePreview(
|
||||
id = conversation.otherUserId,
|
||||
displayName = conversation.displayName.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
fun put(profile: UserProfile) {
|
||||
if (profile.isClientPreviewOnly) {
|
||||
val hasIdentity =
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package ru.fromchat.api.local.db.store
|
||||
|
||||
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
|
||||
|
||||
/**
|
||||
* In-memory cache of the server-provided public chat profile (title, bio, member count).
|
||||
*/
|
||||
object PublicChatProfileCache {
|
||||
var profile: PublicChatProfile? = null
|
||||
private set
|
||||
|
||||
fun put(profile: PublicChatProfile) {
|
||||
this.profile = profile
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
profile = null
|
||||
}
|
||||
}
|
||||
+3
@@ -39,6 +39,9 @@ internal suspend fun streamEncryptedFileToDisk(
|
||||
header(HttpHeaders.Range, "bytes=$rangeOffset-")
|
||||
}
|
||||
}.execute { response ->
|
||||
if (response.status.value == 404 || response.status.value == 410) {
|
||||
error("HTTP ${response.status.value} for encrypted file download")
|
||||
}
|
||||
if (response.status.value !in 200..299) {
|
||||
error("HTTP ${response.status.value} for encrypted file download")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package ru.fromchat.api.local.send
|
||||
|
||||
import io.ktor.client.network.sockets.ConnectTimeoutException
|
||||
import io.ktor.client.network.sockets.SocketTimeoutException
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.plugins.HttpRequestTimeoutException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
||||
/** Error key stored on [ru.fromchat.api.schema.messages.Message.uploadError] for localized send-failure UI. */
|
||||
const val SEND_ERROR_FAILED = "send_failed"
|
||||
|
||||
/** HTTP status codes that mark a permanent send failure (show error, offer retry/cancel). */
|
||||
private val permanentHttpStatuses = setOf(400, 401, 403, 404, 422, 500)
|
||||
|
||||
fun Throwable.isOutboundPermanentFailure(): Boolean = when (this) {
|
||||
is ClientRequestException -> response.status.value in permanentHttpStatuses
|
||||
else -> false
|
||||
}
|
||||
|
||||
/** Transient failures: keep pending and retry (no network, timeouts, other server errors). */
|
||||
fun Throwable.isOutboundTransientFailure(): Boolean = when {
|
||||
isOutboundPermanentFailure() -> false
|
||||
this is TimeoutCancellationException ||
|
||||
this is HttpRequestTimeoutException ||
|
||||
this is SocketTimeoutException ||
|
||||
this is ConnectTimeoutException -> true
|
||||
this is ClientRequestException -> true
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun outboundFailureErrorKey(error: Throwable): String = SEND_ERROR_FAILED
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.fromchat.api.local.send
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class OutboundSendProgress {
|
||||
data class Pending(val clientMessageId: String) : OutboundSendProgress()
|
||||
data class Failed(val clientMessageId: String, val error: String) : OutboundSendProgress()
|
||||
}
|
||||
|
||||
/** Text / public-chat outbound send status (outbox worker → UI). */
|
||||
object OutboundSendNotifier {
|
||||
private val _progressFlow = MutableSharedFlow<OutboundSendProgress>(extraBufferCapacity = 64)
|
||||
val progressFlow: SharedFlow<OutboundSendProgress> = _progressFlow
|
||||
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
fun emit(progress: OutboundSendProgress) {
|
||||
mainScope.launch {
|
||||
_progressFlow.emit(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-2
@@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
|
||||
import ru.fromchat.api.local.workers.AttachmentUploadProgress
|
||||
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
|
||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||
@@ -17,7 +18,11 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.messages.conversationIdForDm
|
||||
import ru.fromchat.api.local.messages.conversationIdForGroup
|
||||
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
|
||||
import ru.fromchat.api.local.send.OutboundSendNotifier
|
||||
import ru.fromchat.api.local.send.OutboundSendProgress
|
||||
import ru.fromchat.api.local.send.isOutboundPermanentFailure
|
||||
import ru.fromchat.api.local.send.isOutboundTransientFailure
|
||||
import ru.fromchat.api.local.send.outboundFailureErrorKey
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.SendDmFile
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
@@ -178,6 +183,21 @@ object OutgoingMessageCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-queues a failed public / text outbound row (outbox row must still exist). */
|
||||
fun retryOutboundMessage(clientMessageId: String, conversationId: String) {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return
|
||||
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||
if (instanceId.isEmpty()) return
|
||||
drainScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.clearSendFailed(conversationId, cid)
|
||||
}
|
||||
OutboundSendNotifier.emit(OutboundSendProgress.Pending(cid))
|
||||
kickOutboxDrain(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-queues a failed attachment upload (outbox row must still exist). */
|
||||
fun retryDmAttachmentUpload(clientMessageId: String) {
|
||||
val cid = clientMessageId.trim()
|
||||
@@ -233,13 +253,34 @@ object OutgoingMessageCoordinator {
|
||||
for (row in rows) {
|
||||
when (row.kind) {
|
||||
KIND_SEND_PUBLIC -> {
|
||||
runCatching {
|
||||
val sendResult = runCatching {
|
||||
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson)
|
||||
ApiClient.sendMessageViaHttp(payload.content, payload.replyToId)
|
||||
}
|
||||
sendResult.onSuccess {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
.deleteOutboxItem(id, row.clientMessageId)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
when {
|
||||
error.isOutboundPermanentFailure() -> {
|
||||
val errorKey = outboundFailureErrorKey(error)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId)
|
||||
}
|
||||
OutboundSendNotifier.emit(
|
||||
OutboundSendProgress.Failed(row.clientMessageId, errorKey),
|
||||
)
|
||||
}
|
||||
error.isOutboundTransientFailure() -> {
|
||||
allOk = false
|
||||
drainScope.launch {
|
||||
delay(3_000)
|
||||
drainOutboxForInstance(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KIND_SEND_DM -> {
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package ru.fromchat.api.schema.chats.publicchat
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PublicChatProfile(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val bio: String? = null,
|
||||
val member_count: Int,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.fromchat.api.schema.messages
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MarkReadRequest(
|
||||
val messageIds: List<Int>,
|
||||
)
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ru.fromchat.api.schema.messages.dm
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DmArchiveRequest(
|
||||
val archived: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.fromchat.api.schema.user
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UsersSearchResponse(
|
||||
val users: List<User> = emptyList(),
|
||||
)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package ru.fromchat.api.schema.user.profile
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UpdateProfileRequest(
|
||||
val username: String? = null,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
val description: String? = null,
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ru.fromchat.api.schema.user.profile
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UpdateProfileResponse(
|
||||
val message: String,
|
||||
val username: String,
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
val bio: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
sealed class DocumentLoadResult {
|
||||
data class Success(
|
||||
val markdown: String,
|
||||
val isCached: Boolean,
|
||||
) : DocumentLoadResult()
|
||||
|
||||
data object Failure : DocumentLoadResult()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object DocumentRepository {
|
||||
private const val RETRY_WINDOW_MS = 5_000L
|
||||
private const val RETRY_DELAY_MS = 1_000L
|
||||
|
||||
private var cachedInstanceId: String? = null
|
||||
private val memoryCache = mutableMapOf<DocumentType, String>()
|
||||
|
||||
fun invalidate() {
|
||||
memoryCache.clear()
|
||||
cachedInstanceId = null
|
||||
}
|
||||
|
||||
private fun ensureInstanceFresh() {
|
||||
val active = CacheContext.activeInstanceId.value.trim()
|
||||
|
||||
if (cachedInstanceId != null && cachedInstanceId != active) {
|
||||
memoryCache.clear()
|
||||
}
|
||||
|
||||
cachedInstanceId = active
|
||||
}
|
||||
|
||||
private fun persistentCacheKey(type: DocumentType) =
|
||||
"legal_doc_cache_v1_${CacheContext.activeInstanceId.value.trim().ifEmpty { "default" }}_${type.name}"
|
||||
|
||||
private suspend fun readPersistentCache(type: DocumentType): String? {
|
||||
ensureInstanceFresh()
|
||||
|
||||
return settings.getString(persistentCacheKey(type)).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private suspend fun writePersistentCache(type: DocumentType, markdown: String) {
|
||||
settings.putString(persistentCacheKey(type), markdown)
|
||||
memoryCache[type] = markdown
|
||||
}
|
||||
|
||||
suspend fun fetch(type: DocumentType): DocumentLoadResult {
|
||||
ensureInstanceFresh()
|
||||
|
||||
val start = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
while (true) {
|
||||
runCatching {
|
||||
return DocumentLoadResult.Success(
|
||||
ApiClient.http.get(
|
||||
"${ServerConfig.apiBaseUrl}/static/${type.fileName}"
|
||||
).bodyAsText().also {
|
||||
writePersistentCache(type, it)
|
||||
},
|
||||
isCached = false
|
||||
)
|
||||
}
|
||||
|
||||
if (Clock.System.now().toEpochMilliseconds() - start >= RETRY_WINDOW_MS) {
|
||||
break
|
||||
}
|
||||
|
||||
delay(RETRY_DELAY_MS.milliseconds)
|
||||
}
|
||||
|
||||
(memoryCache[type] ?: readPersistentCache(type)).also {
|
||||
if (it != null) {
|
||||
memoryCache[type] = it
|
||||
return DocumentLoadResult.Success(it, isCached = true)
|
||||
}
|
||||
}
|
||||
|
||||
return DocumentLoadResult.Failure
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MediumTopAppBar
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.about_link_privacy
|
||||
import ru.fromchat.about_link_terms
|
||||
import ru.fromchat.attachment_retry
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.legal_document_cached_banner
|
||||
import ru.fromchat.legal_document_load_error
|
||||
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
private data class PendingDocument(
|
||||
val parsed: ParsedDocument,
|
||||
val isCached: Boolean,
|
||||
)
|
||||
|
||||
private sealed interface DocumentDisplayState {
|
||||
data object Loading : DocumentDisplayState
|
||||
|
||||
data object Error : DocumentDisplayState
|
||||
|
||||
data class Success(
|
||||
val parsed: ParsedDocument,
|
||||
) : DocumentDisplayState
|
||||
|
||||
data class CachedSuccess(
|
||||
val parsed: ParsedDocument,
|
||||
) : DocumentDisplayState
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalMaterial3ExpressiveApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun DocumentScreen(
|
||||
type: DocumentType,
|
||||
onBack: () -> Unit,
|
||||
onOpenLegalDocument: (DocumentType) -> Unit,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
val listState = rememberLazyListState()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
LaunchedEffect(type) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
|
||||
ToggleNavScrimEffect()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
topBar = {
|
||||
MediumTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = when (type) {
|
||||
DocumentType.Privacy -> stringResource(Res.string.about_link_privacy)
|
||||
DocumentType.Terms -> stringResource(Res.string.about_link_terms)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = rememberChatSurfaceContainerHazeStyle(),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
var loadAttempt by remember(type) { mutableIntStateOf(0) }
|
||||
var displayState by remember(type) { mutableStateOf<DocumentDisplayState>(DocumentDisplayState.Loading) }
|
||||
var pendingDocument by remember(type) { mutableStateOf<PendingDocument?>(null) }
|
||||
|
||||
val topContentPadding = innerPadding.calculateTopPadding()
|
||||
val bottomContentPadding = innerPadding.calculateBottomPadding() + 16.dp
|
||||
|
||||
LaunchedEffect(type, loadAttempt) {
|
||||
displayState = DocumentDisplayState.Loading
|
||||
pendingDocument = null
|
||||
|
||||
when (val result = DocumentRepository.fetch(type)) {
|
||||
is DocumentLoadResult.Success -> {
|
||||
pendingDocument = PendingDocument(
|
||||
parsed = parseMarkdown(result.markdown),
|
||||
isCached = result.isCached,
|
||||
)
|
||||
}
|
||||
|
||||
DocumentLoadResult.Failure -> {
|
||||
displayState = DocumentDisplayState.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingDocument) {
|
||||
val pending = pendingDocument ?: return@LaunchedEffect
|
||||
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
|
||||
displayState = if (pending.isCached) {
|
||||
DocumentDisplayState.CachedSuccess(pending.parsed)
|
||||
} else {
|
||||
DocumentDisplayState.Success(pending.parsed)
|
||||
}
|
||||
pendingDocument = null
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
pendingDocument?.let { pending ->
|
||||
DocumentContent(
|
||||
parsed = pending.parsed,
|
||||
showCachedBanner = pending.isCached,
|
||||
listState = listState,
|
||||
onOpenLegalDocument = onOpenLegalDocument,
|
||||
topContentPadding = topContentPadding,
|
||||
bottomContentPadding = bottomContentPadding,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(0f),
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = displayState,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "legal_document_${type.name}",
|
||||
) { state ->
|
||||
when (state) {
|
||||
DocumentDisplayState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topContentPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
LoadingIndicator(modifier = Modifier.padding(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
DocumentDisplayState.Error -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topContentPadding)
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.legal_document_load_error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
ActionButton(onClick = { loadAttempt++ }) {
|
||||
Text(stringResource(Res.string.attachment_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DocumentDisplayState.Success -> {
|
||||
DocumentContent(
|
||||
parsed = state.parsed,
|
||||
showCachedBanner = false,
|
||||
listState = listState,
|
||||
onOpenLegalDocument = onOpenLegalDocument,
|
||||
topContentPadding = topContentPadding,
|
||||
bottomContentPadding = bottomContentPadding,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is DocumentDisplayState.CachedSuccess -> {
|
||||
DocumentContent(
|
||||
parsed = state.parsed,
|
||||
showCachedBanner = true,
|
||||
listState = listState,
|
||||
onOpenLegalDocument = onOpenLegalDocument,
|
||||
topContentPadding = topContentPadding,
|
||||
bottomContentPadding = bottomContentPadding,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocumentContent(
|
||||
parsed: ParsedDocument,
|
||||
showCachedBanner: Boolean,
|
||||
listState: LazyListState,
|
||||
onOpenLegalDocument: (DocumentType) -> Unit,
|
||||
topContentPadding: Dp,
|
||||
bottomContentPadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val defaultUriHandler = LocalUriHandler.current
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalUriHandler provides remember(onOpenLegalDocument, defaultUriHandler) {
|
||||
object : UriHandler {
|
||||
override fun openUri(uri: String) {
|
||||
resolveLegalDocumentType(uri)?.let { linkedType ->
|
||||
onOpenLegalDocument(linkedType)
|
||||
return
|
||||
}
|
||||
|
||||
defaultUriHandler.openUri(uri)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
SelectionContainer(modifier = modifier) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(
|
||||
top = topContentPadding,
|
||||
bottom = bottomContentPadding,
|
||||
),
|
||||
) {
|
||||
if (showCachedBanner) {
|
||||
item(key = "cached-banner") {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.legal_document_cached_banner),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.preamble.isNotBlank()) {
|
||||
item(key = "preamble") {
|
||||
Markdown(
|
||||
content = parsed.preamble,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
parsed.sections.forEachIndexed { index, section ->
|
||||
item(key = "section-$index") {
|
||||
Column {
|
||||
ExpressiveSectionHeader(
|
||||
directive = section.directive,
|
||||
title = section.title,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Markdown(
|
||||
content = section.bodyMarkdown,
|
||||
modifier = Modifier.padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
enum class DocumentType(
|
||||
val fileName: String,
|
||||
val routeSegment: String,
|
||||
) {
|
||||
Privacy("PRIVACY.md", "privacy"),
|
||||
Terms("TERMS.md", "terms"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
const val ROUTE_PREFIX = "document"
|
||||
const val ROUTE = "$ROUTE_PREFIX/{documentType}"
|
||||
const val ARG_DOCUMENT_TYPE = "documentType"
|
||||
|
||||
fun route(type: DocumentType): String = "$ROUTE_PREFIX/${type.routeSegment}"
|
||||
|
||||
fun typeFromArg(arg: String): DocumentType? =
|
||||
entries.firstOrNull { it.routeSegment.equals(arg, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.PrivacyTip
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
private const val LINK_PRESS_SCALE = 0.92f
|
||||
|
||||
private fun AnnotatedString.linkAt(offset: Int): AnnotatedString.Range<LinkAnnotation>? {
|
||||
if (isEmpty()) return null
|
||||
|
||||
val clamped = offset.coerceIn(0, lastIndex)
|
||||
return getLinkAnnotations(clamped, clamped + 1).firstOrNull()
|
||||
?: getLinkAnnotations(0, length).firstOrNull { clamped in it.start until it.end }
|
||||
}
|
||||
|
||||
private val LEGAL_STATIC_LINK_RE = Regex(
|
||||
pattern = """(?:^|/)?(?:api/)?static/(TERMS|PRIVACY)\.md$""",
|
||||
option = RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
private val SECTION_DIRECTIVE_REGEX = Regex("""<!--\s*fc:([^>]+?)\s*-->""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val LEGAL_MATERIAL_ICON_ALIASES = mapOf(
|
||||
"privacy" to "privacy_tip",
|
||||
"terms" to "contract",
|
||||
)
|
||||
|
||||
fun materialIconName(iconKey: String) = LEGAL_MATERIAL_ICON_ALIASES[iconKey] ?: iconKey
|
||||
|
||||
fun materialIconVector(iconKey: String): ImageVector = when (materialIconName(iconKey)) {
|
||||
"privacy_tip" -> Icons.Filled.PrivacyTip
|
||||
"contract" -> Icons.AutoMirrored.Filled.Article
|
||||
"person_add" -> Icons.Filled.PersonAdd
|
||||
"chat" -> Icons.AutoMirrored.Filled.Chat
|
||||
"storage" -> Icons.Filled.Storage
|
||||
"shield" -> Icons.Filled.Shield
|
||||
"visibility_off" -> Icons.Filled.VisibilityOff
|
||||
"block" -> Icons.Filled.Block
|
||||
"call" -> Icons.Filled.Call
|
||||
"delete" -> Icons.Filled.Delete
|
||||
"description" -> Icons.Filled.Description
|
||||
"lock" -> Icons.Filled.Lock
|
||||
"notifications" -> Icons.Filled.Notifications
|
||||
"person" -> Icons.Filled.Person
|
||||
"phone" -> Icons.Filled.Phone
|
||||
else -> Icons.Filled.Info
|
||||
}
|
||||
|
||||
data class SectionDirective(
|
||||
val shape: String,
|
||||
val icon: String,
|
||||
)
|
||||
|
||||
data class Section(
|
||||
val directive: SectionDirective,
|
||||
val title: String,
|
||||
val bodyMarkdown: String,
|
||||
)
|
||||
|
||||
data class ParsedDocument(
|
||||
val preamble: String,
|
||||
val sections: List<Section>,
|
||||
)
|
||||
|
||||
private data class MarkdownTable(
|
||||
val header: List<String>,
|
||||
val rows: List<List<String>>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun Markdown(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
|
||||
/** Subsection (`###`) size: geometric middle between body text and expressive section title. */
|
||||
@Composable
|
||||
fun markdownSubsectionStyle(): TextStyle {
|
||||
val body = MaterialTheme.typography.bodyMedium
|
||||
val sectionTitle = MaterialTheme.typography.titleLarge
|
||||
|
||||
return body.copy(
|
||||
fontSize = ((body.fontSize.value + sectionTitle.fontSize.value) / 2).sp,
|
||||
lineHeight = ((body.lineHeight.value + sectionTitle.lineHeight.value) / 2).sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps static legal Markdown API paths to in-app legal document types.
|
||||
*/
|
||||
fun resolveLegalDocumentType(uri: String): DocumentType? = when (
|
||||
(
|
||||
LEGAL_STATIC_LINK_RE.find(
|
||||
uri
|
||||
.replace('\\', '/')
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.trimEnd('/')
|
||||
) ?: return null
|
||||
).groupValues[1].uppercase()
|
||||
) {
|
||||
"TERMS" -> DocumentType.Terms
|
||||
"PRIVACY" -> DocumentType.Privacy
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun parseSectionDirective(line: String): SectionDirective? {
|
||||
val body = (SECTION_DIRECTIVE_REGEX.find(line.trim()) ?: return null).groupValues[1]
|
||||
|
||||
return SectionDirective(
|
||||
shape = Regex("""shape=([A-Za-z0-9_]+)""")
|
||||
.find(body)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?: return null,
|
||||
icon = Regex("""icon=([A-Za-z0-9_-]+)""")
|
||||
.find(body)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?: return null
|
||||
)
|
||||
}
|
||||
|
||||
fun parseMarkdown(markdown: String): ParsedDocument {
|
||||
val lines = markdown.replace("\r\n", "\n").split("\n")
|
||||
val preambleLines = mutableListOf<String>()
|
||||
val sections = mutableListOf<Section>()
|
||||
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
val directive = parseSectionDirective(lines[i])
|
||||
|
||||
when {
|
||||
directive != null && i + 1 < lines.size && lines[i + 1].startsWith("## ") -> {
|
||||
val title = lines[i + 1].removePrefix("## ").trim()
|
||||
|
||||
i += 2
|
||||
|
||||
sections += Section(
|
||||
directive = directive,
|
||||
title = title,
|
||||
bodyMarkdown = buildList {
|
||||
while (i < lines.size) {
|
||||
if (
|
||||
parseSectionDirective(lines[i]) != null &&
|
||||
i + 1 < lines.size &&
|
||||
lines[i + 1].startsWith("## ")
|
||||
) break
|
||||
|
||||
add(lines[i])
|
||||
i += 1
|
||||
}
|
||||
}.joinToString("\n").trim(),
|
||||
)
|
||||
}
|
||||
|
||||
sections.isEmpty() -> {
|
||||
preambleLines += lines[i]
|
||||
i += 1
|
||||
}
|
||||
|
||||
else -> i += 1
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedDocument(
|
||||
preamble = preambleLines.joinToString("\n").trim(),
|
||||
sections = sections,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MarkdownPlain(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onLinkClick: (String) -> Unit,
|
||||
) {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium
|
||||
val linkStyle = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
|
||||
Column(modifier = modifier) {
|
||||
val lines = content.lineSequence().toList()
|
||||
var index = 0
|
||||
|
||||
while (index < lines.size) {
|
||||
val trimmed = lines[index].trimEnd()
|
||||
|
||||
when {
|
||||
trimmed.isEmpty() -> {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
index += 1
|
||||
}
|
||||
|
||||
isLegalMarkdownTableLine(trimmed) -> {
|
||||
val tableLines = buildList {
|
||||
while (index < lines.size && isLegalMarkdownTableLine(lines[index].trimEnd())) {
|
||||
this += lines[index].trimEnd()
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
parseMarkdownTable(tableLines).let {
|
||||
if (it != null) {
|
||||
LegalMarkdownTableView(
|
||||
table = it,
|
||||
linkStyle = linkStyle,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
} else {
|
||||
tableLines.forEach { line ->
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(line, linkStyle, onLinkClick),
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trimmed.startsWith("### ") -> {
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(trimmed.removePrefix("### "), linkStyle, onLinkClick),
|
||||
style = markdownSubsectionStyle(),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
trimmed.startsWith("- ") -> {
|
||||
Row(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) {
|
||||
Text(
|
||||
text = "• ",
|
||||
style = bodyStyle,
|
||||
)
|
||||
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(trimmed.removePrefix("- "), linkStyle, onLinkClick),
|
||||
style = bodyStyle,
|
||||
)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
else -> {
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(trimmed, linkStyle, onLinkClick),
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MarkdownBlockText(
|
||||
text: AnnotatedString,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var layoutResult by remember(text) { mutableStateOf<TextLayoutResult?>(null) }
|
||||
var pressedLinkRange by remember(text) { mutableStateOf<IntRange?>(null) }
|
||||
val currentLayoutResult by rememberUpdatedState(layoutResult)
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressedLinkRange != null) LINK_PRESS_SCALE else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
),
|
||||
label = "legal_link_press_scale",
|
||||
)
|
||||
|
||||
// graphicsLayer on BasicText does not scale the text draw pass; wrap in Box (see MessageItem).
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
val range = pressedLinkRange
|
||||
val layout = layoutResult
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
if (range != null && layout != null && layout.size.width > 0f && layout.size.height > 0f) {
|
||||
val bounds = layout.getPathForRange(range.first, range.last + 1).getBounds()
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(
|
||||
pivotFractionX = (bounds.left + bounds.right) / 2f / layout.size.width,
|
||||
pivotFractionY = (bounds.top + bounds.bottom) / 2f / layout.size.height,
|
||||
)
|
||||
} else {
|
||||
scaleX = 1f
|
||||
scaleY = 1f
|
||||
transformOrigin = TransformOrigin.Center
|
||||
}
|
||||
}
|
||||
.pointerInput(text) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(
|
||||
pass = PointerEventPass.Initial,
|
||||
requireUnconsumed = false,
|
||||
)
|
||||
val layout = currentLayoutResult ?: return@awaitEachGesture
|
||||
val link = text.linkAt(layout.getOffsetForPosition(down.position)) ?: return@awaitEachGesture
|
||||
|
||||
pressedLinkRange = link.start until link.end
|
||||
try {
|
||||
waitForUpOrCancellation()
|
||||
} finally {
|
||||
pressedLinkRange = null
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = style.merge(color = MaterialTheme.colorScheme.onSurface),
|
||||
onTextLayout = { layoutResult = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseInlineMarkdown(
|
||||
text: String,
|
||||
linkStyle: SpanStyle,
|
||||
onLinkClick: (String) -> Unit,
|
||||
): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
var index = 0
|
||||
while (index < text.length) {
|
||||
when {
|
||||
text.startsWith("**", index) -> {
|
||||
val end = text.indexOf("**", index + 2)
|
||||
if (end != -1) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(text.substring(index + 2, end))
|
||||
}
|
||||
index = end + 2
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
text[index] == '[' -> {
|
||||
val closeBracket = text.indexOf(']', index + 1)
|
||||
val openParen = if (closeBracket != -1) text.indexOf('(', closeBracket + 1) else -1
|
||||
val closeParen = if (openParen != -1) text.indexOf(')', openParen + 1) else -1
|
||||
|
||||
if (closeBracket != -1 && openParen == closeBracket + 1 && closeParen != -1) {
|
||||
withLink(
|
||||
LinkAnnotation.Clickable(
|
||||
tag = text.substring(openParen + 1, closeParen),
|
||||
styles = TextLinkStyles(style = linkStyle),
|
||||
linkInteractionListener = { link ->
|
||||
(link as? LinkAnnotation.Clickable)?.tag?.let(onLinkClick)
|
||||
},
|
||||
),
|
||||
) {
|
||||
append(text.substring(index + 1, closeBracket))
|
||||
}
|
||||
|
||||
index = closeParen + 1
|
||||
} else {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(text[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMarkdownTable(lines: List<String>): MarkdownTable? {
|
||||
return if (lines.size < 2 || !isMarkdownTableSeparator(lines[1])) null else MarkdownTable(
|
||||
header = parseMarkdownTableRow(lines.first()) ?: return null,
|
||||
rows = lines.drop(2).mapNotNull(::parseMarkdownTableRow)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isLegalMarkdownTableLine(line: String) =
|
||||
line.trim().startsWith("|") && line.trim().endsWith("|")
|
||||
|
||||
private fun parseMarkdownTableRow(line: String) =
|
||||
if (!isLegalMarkdownTableLine(line)) null else
|
||||
line.trim().trim('|').split('|').map { it.trim() }
|
||||
|
||||
private fun isMarkdownTableSeparator(line: String) =
|
||||
parseMarkdownTableRow(line)?.all { cell ->
|
||||
cell.all { it == '-' || it == ':' || it.isWhitespace() }
|
||||
} == true
|
||||
|
||||
@Composable
|
||||
private fun LegalMarkdownTableView(
|
||||
table: MarkdownTable,
|
||||
linkStyle: SpanStyle,
|
||||
onLinkClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||
val borderColor = MaterialTheme.colorScheme.outlineVariant
|
||||
val columnCount = maxOf(
|
||||
table.header.size,
|
||||
table.rows.maxOfOrNull { it.size } ?: 0
|
||||
)
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
repeat(columnCount) { columnIndex ->
|
||||
MarkdownTableCell(
|
||||
text = table.header.getOrNull(columnIndex).orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
borderColor = borderColor,
|
||||
linkStyle = linkStyle,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
table.rows.forEach { row ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
repeat(columnCount) { columnIndex ->
|
||||
MarkdownTableCell(
|
||||
text = row.getOrNull(columnIndex).orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
borderColor = borderColor,
|
||||
linkStyle = linkStyle,
|
||||
onLinkClick = onLinkClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownTableCell(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
backgroundColor: Color,
|
||||
borderColor: Color,
|
||||
linkStyle: SpanStyle,
|
||||
onLinkClick: (String) -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(160.dp)
|
||||
.fillMaxHeight()
|
||||
.background(backgroundColor)
|
||||
.border(0.5.dp, borderColor)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
MarkdownBlockText(
|
||||
text = parseInlineMarkdown(text, linkStyle, onLinkClick),
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ExpressiveSectionHeader(
|
||||
directive: SectionDirective,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(110.dp)
|
||||
.clip(
|
||||
when (directive.shape) {
|
||||
"Arch" -> MaterialShapes.Arch
|
||||
"Arrow" -> MaterialShapes.Arrow
|
||||
"Boom" -> MaterialShapes.Boom
|
||||
"Bun" -> MaterialShapes.Bun
|
||||
"Burst" -> MaterialShapes.Burst
|
||||
"ClamShell" -> MaterialShapes.ClamShell
|
||||
"Clover4Leaf" -> MaterialShapes.Clover4Leaf
|
||||
"Clover8Leaf" -> MaterialShapes.Clover8Leaf
|
||||
"Cookie12Sided" -> MaterialShapes.Cookie12Sided
|
||||
"Cookie4Sided" -> MaterialShapes.Cookie4Sided
|
||||
"Cookie6Sided" -> MaterialShapes.Cookie6Sided
|
||||
"Cookie7Sided" -> MaterialShapes.Cookie7Sided
|
||||
"Cookie9Sided" -> MaterialShapes.Cookie9Sided
|
||||
"Diamond" -> MaterialShapes.Diamond
|
||||
"Fan" -> MaterialShapes.Fan
|
||||
"Flower" -> MaterialShapes.Flower
|
||||
"Gem" -> MaterialShapes.Gem
|
||||
"Ghostish" -> MaterialShapes.Ghostish
|
||||
"Heart" -> MaterialShapes.Heart
|
||||
"Oval" -> MaterialShapes.Oval
|
||||
"Pentagon" -> MaterialShapes.Pentagon
|
||||
"Pill" -> MaterialShapes.Pill
|
||||
"PixelCircle" -> MaterialShapes.PixelCircle
|
||||
"PixelTriangle" -> MaterialShapes.PixelTriangle
|
||||
"Puffy" -> MaterialShapes.Puffy
|
||||
"PuffyDiamond" -> MaterialShapes.PuffyDiamond
|
||||
"SemiCircle" -> MaterialShapes.SemiCircle
|
||||
"Slanted" -> MaterialShapes.Slanted
|
||||
"SoftBoom" -> MaterialShapes.SoftBoom
|
||||
"SoftBurst" -> MaterialShapes.SoftBurst
|
||||
"Square" -> MaterialShapes.Square
|
||||
"Sunny" -> MaterialShapes.Sunny
|
||||
"Triangle" -> MaterialShapes.Triangle
|
||||
"VerySunny" -> MaterialShapes.VerySunny
|
||||
else -> MaterialShapes.Circle
|
||||
}.normalized().toShape()
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = materialIconVector(directive.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(50.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
@@ -25,9 +24,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
@@ -38,7 +37,9 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import coil3.ImageLoader
|
||||
import coil3.compose.setSingletonImageLoaderFactory
|
||||
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||
import coil3.svg.SvgDecoder
|
||||
import ru.fromchat.api.ApiClient
|
||||
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
|
||||
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
|
||||
import com.pr0gramm3r101.utils.rememberSystemBarsController
|
||||
@@ -54,7 +55,6 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.AppForeground
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.DeferredStartupNetwork
|
||||
import ru.fromchat.api.UpdateSyncManager
|
||||
import ru.fromchat.api.calls.CallStore
|
||||
@@ -70,12 +70,16 @@ import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.legal.DocumentScreen
|
||||
import ru.fromchat.legal.DocumentType
|
||||
import ru.fromchat.ui.auth.AuthScreen
|
||||
import ru.fromchat.ui.calls.CallOverlay
|
||||
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
||||
import ru.fromchat.ui.chat.panels.dm.DmNav
|
||||
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
|
||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatScreen
|
||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute
|
||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
||||
import ru.fromchat.ui.main.settings.AboutScreen
|
||||
@@ -84,14 +88,43 @@ import ru.fromchat.ui.main.settings.DevicesScreen
|
||||
import ru.fromchat.ui.main.settings.NotificationsScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.account.AccountScreen
|
||||
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
||||
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
||||
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
||||
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
|
||||
import ru.fromchat.ui.profile.EditProfileFocusField
|
||||
import ru.fromchat.ui.profile.EditProfileScreen
|
||||
import ru.fromchat.ui.profile.ProfileRoutes
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.utils.NetworkConnectivity
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||
|
||||
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||
|
||||
private fun rootNavEnterTransition(): EnterTransition =
|
||||
scaleIn(initialScale = 0.9f, animationSpec = rootNavTween) +
|
||||
fadeIn(animationSpec = rootNavTween)
|
||||
|
||||
private fun rootNavExitTransition(): ExitTransition =
|
||||
scaleOut(targetScale = 1.1f, animationSpec = rootNavTween) +
|
||||
fadeOut(animationSpec = rootNavTween)
|
||||
|
||||
private fun rootNavPopEnterTransition(): EnterTransition =
|
||||
scaleIn(initialScale = 1.1f, animationSpec = rootNavTween) +
|
||||
fadeIn(animationSpec = rootNavTween)
|
||||
|
||||
private fun rootNavPopExitTransition(): ExitTransition =
|
||||
scaleOut(targetScale = 0.9f, animationSpec = rootNavTween) +
|
||||
fadeOut(animationSpec = rootNavTween)
|
||||
|
||||
private val searchScreenFade = tween<Float>(durationMillis = 260)
|
||||
|
||||
private fun searchScreenEnterTransition(): EnterTransition =
|
||||
fadeIn(animationSpec = searchScreenFade)
|
||||
|
||||
private fun searchScreenExitTransition(): ExitTransition =
|
||||
fadeOut(animationSpec = searchScreenFade)
|
||||
|
||||
private fun handlePresenceStatus(data: JsonObject?) {
|
||||
val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return
|
||||
val online = data["online"]?.jsonPrimitive?.booleanOrNull == true
|
||||
@@ -149,25 +182,14 @@ private fun handlePresenceEvent(message: WebSocketMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.settingsSlideComposable(
|
||||
private fun NavGraphBuilder.settingsComposable(
|
||||
route: String,
|
||||
animationSpec: FiniteAnimationSpec<IntOffset>,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
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 }
|
||||
},
|
||||
arguments = arguments,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -186,6 +208,7 @@ fun App(
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(SvgDecoder.Factory())
|
||||
add(KtorNetworkFetcherFactory(httpClient = { ApiClient.http }))
|
||||
}
|
||||
.build()
|
||||
}
|
||||
@@ -287,7 +310,6 @@ fun App(
|
||||
SharedTransitionLayout {
|
||||
val navController = rememberNavController()
|
||||
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(profileLookupErrorMessage) {
|
||||
profileLookupErrorMessage?.let { message ->
|
||||
Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
|
||||
@@ -359,35 +381,14 @@ fun App(
|
||||
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
||||
) {
|
||||
if (startDestination != null) {
|
||||
val rootNavMotion = spring<IntOffset>(dampingRatio = 0.88f, stiffness = 420f)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
Start,
|
||||
animationSpec = rootNavMotion
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
Start,
|
||||
animationSpec = rootNavMotion
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
End,
|
||||
animationSpec = rootNavMotion
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
End,
|
||||
animationSpec = rootNavMotion
|
||||
)
|
||||
}
|
||||
enterTransition = { rootNavEnterTransition() },
|
||||
exitTransition = { rootNavExitTransition() },
|
||||
popEnterTransition = { rootNavPopEnterTransition() },
|
||||
popExitTransition = { rootNavPopExitTransition() },
|
||||
) {
|
||||
composable("serverConfig") {
|
||||
ServerConfigScreen()
|
||||
@@ -426,21 +427,29 @@ fun App(
|
||||
)
|
||||
}
|
||||
|
||||
composable("chats/publicChat") {
|
||||
PublicChatScreen(
|
||||
composable(PublicChatNav.CHAT_ROUTE) {
|
||||
PublicChatChatRoute(
|
||||
scrollToMessageId = scrollToMessageId,
|
||||
navController = navController,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedContentScope = this@composable
|
||||
animatedVisibilityScope = this,
|
||||
)
|
||||
}
|
||||
|
||||
composable(PublicChatNav.PROFILE_ROUTE) {
|
||||
PublicChatProfileRoute(
|
||||
navController = navController,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this,
|
||||
)
|
||||
}
|
||||
|
||||
val searchScreenFade = tween<Float>(260)
|
||||
composable(
|
||||
route = "search/conversations",
|
||||
enterTransition = { fadeIn(animationSpec = searchScreenFade) },
|
||||
exitTransition = { fadeOut(animationSpec = searchScreenFade) },
|
||||
popEnterTransition = { fadeIn(animationSpec = searchScreenFade) },
|
||||
popExitTransition = { fadeOut(animationSpec = searchScreenFade) }
|
||||
enterTransition = { searchScreenEnterTransition() },
|
||||
exitTransition = { searchScreenExitTransition() },
|
||||
popEnterTransition = { searchScreenEnterTransition() },
|
||||
popExitTransition = { searchScreenExitTransition() },
|
||||
) {
|
||||
ChatsSearchScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
@@ -460,21 +469,13 @@ fun App(
|
||||
}
|
||||
|
||||
composable(
|
||||
route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
|
||||
route = "profile/{userId}?fromDeepLink={fromDeepLink}",
|
||||
arguments = listOf(
|
||||
navArgument("userId") { type = NavType.StringType },
|
||||
navArgument("fromDeepLink") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument("useSharedElement") {
|
||||
type = NavType.StringType
|
||||
defaultValue = "false"
|
||||
},
|
||||
navArgument("sourceMessageId") {
|
||||
type = NavType.StringType
|
||||
defaultValue = "-1"
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val args = backStackEntry.savedStateHandle
|
||||
@@ -483,20 +484,8 @@ fun App(
|
||||
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
|
||||
val profileUsername = if (userId == null) {
|
||||
userIdParam?.trim()?.takeIf { it.isNotBlank() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val useSharedElement = when (val rawUseSharedElement = args.get<Any?>("useSharedElement")) {
|
||||
is Boolean -> rawUseSharedElement
|
||||
is String -> rawUseSharedElement == "true"
|
||||
else -> false
|
||||
}
|
||||
val sourceMessageId = when (val rawSourceMessageId = args.get<Any?>("sourceMessageId")) {
|
||||
is Int -> rawSourceMessageId
|
||||
is String -> rawSourceMessageId.toIntOrNull() ?: -1
|
||||
is Long -> rawSourceMessageId.toInt()
|
||||
else -> -1
|
||||
}
|
||||
} else null
|
||||
|
||||
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
|
||||
is Boolean -> rawFromDeepLink
|
||||
is String -> rawFromDeepLink == "true"
|
||||
@@ -506,24 +495,39 @@ fun App(
|
||||
Logger.d(
|
||||
"ProfileRoute",
|
||||
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
|
||||
"resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " +
|
||||
"useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}"
|
||||
"resolvedUsername=$profileUsername fromDeepLink=$fromDeepLink " +
|
||||
"currentRoute=${backStackEntry.destination.route}"
|
||||
)
|
||||
|
||||
ProfileScreen(
|
||||
userId = userId,
|
||||
username = profileUsername,
|
||||
showBackButton = true,
|
||||
onBack = { navController.navigateUp() },
|
||||
onChat = { navController.navigate(DmNav.chatRoute(it)) },
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@composable,
|
||||
useSharedElementFromNavigation = useSharedElement,
|
||||
sharedSourceMessageId = sourceMessageId,
|
||||
showErrorAsToast = fromDeepLink
|
||||
)
|
||||
}
|
||||
|
||||
val dmChatProfileFade = tween<Float>(durationMillis = 280)
|
||||
composable(
|
||||
route = ProfileRoutes.Edit,
|
||||
arguments = listOf(
|
||||
navArgument(ProfileRoutes.ARG_FOCUS) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
val focusField = EditProfileFocusField.fromArg(
|
||||
entry.arguments?.getString(ProfileRoutes.ARG_FOCUS),
|
||||
)
|
||||
EditProfileScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
initialFocusField = focusField,
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = DmNav.CHAT_ROUTE,
|
||||
@@ -531,30 +535,6 @@ fun App(
|
||||
navArgument("otherUserId") { type = NavType.StringType },
|
||||
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
|
||||
),
|
||||
enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
|
||||
else -> slideIntoContainer(Start, animationSpec = rootNavMotion)
|
||||
}
|
||||
},
|
||||
exitTransition = {
|
||||
when (targetState.destination.route) {
|
||||
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
|
||||
else -> slideOutOfContainer(Start, animationSpec = rootNavMotion)
|
||||
}
|
||||
},
|
||||
popEnterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
|
||||
else -> slideIntoContainer(End, animationSpec = rootNavMotion)
|
||||
}
|
||||
},
|
||||
popExitTransition = {
|
||||
when (targetState.destination.route) {
|
||||
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
|
||||
else -> slideOutOfContainer(End, animationSpec = rootNavMotion)
|
||||
}
|
||||
},
|
||||
) { entry ->
|
||||
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
|
||||
@@ -571,10 +551,6 @@ fun App(
|
||||
composable(
|
||||
route = DmNav.PROFILE_ROUTE,
|
||||
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
|
||||
enterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
|
||||
exitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
|
||||
popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
|
||||
popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
|
||||
) { entry ->
|
||||
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||
if (otherUserId <= 0) return@composable
|
||||
@@ -586,30 +562,51 @@ fun App(
|
||||
)
|
||||
}
|
||||
|
||||
settingsSlideComposable("about", rootNavMotion) {
|
||||
settingsComposable("about") {
|
||||
AboutScreen()
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) {
|
||||
settingsComposable(
|
||||
route = DocumentType.ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(DocumentType.ARG_DOCUMENT_TYPE) { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val type = entry.savedStateHandle
|
||||
.get<String>(DocumentType.ARG_DOCUMENT_TYPE)
|
||||
?.let(DocumentType::typeFromArg)
|
||||
?: return@settingsComposable
|
||||
DocumentScreen(
|
||||
type = type,
|
||||
onBack = { navController.navigateUp() },
|
||||
onOpenLegalDocument = { linkedType ->
|
||||
navController.navigate(DocumentType.route(linkedType)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
settingsComposable(SettingsRoutes.Appearance) {
|
||||
AppearanceScreen(onBack = { navController.navigateUp() })
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) {
|
||||
settingsComposable(SettingsRoutes.Notifications) {
|
||||
NotificationsScreen(onBack = { navController.navigateUp() })
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) {
|
||||
settingsComposable(SettingsRoutes.Devices) {
|
||||
DevicesScreen(onBack = { navController.navigateUp() })
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
|
||||
settingsComposable(SettingsRoutes.SecurityPasswordFlow) {
|
||||
ChangePasswordScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
onDone = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.AccountDeleteFlow, rootNavMotion) {
|
||||
settingsComposable(SettingsRoutes.AccountDeleteFlow) {
|
||||
DeleteAccountScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
onDeleted = {
|
||||
@@ -620,7 +617,7 @@ fun App(
|
||||
)
|
||||
}
|
||||
|
||||
settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) {
|
||||
settingsComposable(SettingsRoutes.Account) {
|
||||
AccountScreen(
|
||||
onBack = { navController.navigateUp() },
|
||||
onLogout = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ru.fromchat.ui.auth.register
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -10,6 +12,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -17,11 +20,16 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.about_link_privacy
|
||||
import ru.fromchat.about_link_terms
|
||||
import ru.fromchat.auth_char_count
|
||||
import ru.fromchat.auth_legal_notice_and
|
||||
import ru.fromchat.auth_legal_notice_prefix
|
||||
import ru.fromchat.auth_step_profile_body
|
||||
import ru.fromchat.auth_step_profile_title
|
||||
import ru.fromchat.auth_username_taken
|
||||
@@ -30,6 +38,8 @@ import ru.fromchat.display_name_error
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.profile_headline_bio
|
||||
import ru.fromchat.register_button
|
||||
import ru.fromchat.legal.DocumentType
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.auth.RegisterResult
|
||||
import ru.fromchat.ui.auth.register
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
@@ -60,6 +70,7 @@ internal fun profileStepPage(
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
val fieldColors = expressiveStepFieldColors()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -123,6 +134,36 @@ internal fun profileStepPage(
|
||||
)
|
||||
},
|
||||
button = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_legal_notice_prefix),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) }) {
|
||||
Text(
|
||||
text = stringResource(Res.string.about_link_terms),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_legal_notice_and),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) }) {
|
||||
Text(
|
||||
text = stringResource(Res.string.about_link_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
ActionButton(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
|
||||
@@ -80,6 +80,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageImage
|
||||
import ru.fromchat.api.local.messages.generateClientMessageId
|
||||
import ru.fromchat.api.local.messages.nowMessageTimestampIso
|
||||
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
|
||||
import ru.fromchat.api.local.send.OutboundSendNotifier
|
||||
import ru.fromchat.api.local.send.OutboundSendProgress
|
||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
import ru.fromchat.api.local.send.prepareOutboundFileForSend
|
||||
import ru.fromchat.api.local.send.prepareOutboundImageForSend
|
||||
@@ -94,7 +96,6 @@ import ru.fromchat.chat_group_label
|
||||
import ru.fromchat.status_connecting
|
||||
import ru.fromchat.status_updating
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.chat.panels.publicchat.publicChatProfileSharedAvatarKey
|
||||
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
|
||||
import ru.fromchat.ui.chat.utils.getImageAspectRatio
|
||||
import ru.fromchat.ui.chat.utils.getImageDimensions
|
||||
@@ -171,13 +172,6 @@ fun ChatScreen(
|
||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
||||
}
|
||||
|
||||
var profileSharedSourceMessageId by remember(scrollToMessageId) {
|
||||
mutableStateOf(scrollToMessageId?.takeIf { it > 0 })
|
||||
}
|
||||
LaunchedEffect(scrollToMessageId) {
|
||||
scrollToMessageId?.takeIf { it > 0 }?.let { profileSharedSourceMessageId = it }
|
||||
}
|
||||
|
||||
val subtitleKey = when {
|
||||
!online -> "connecting"
|
||||
connectionStatus == ConnectionStatus.UPDATING -> "updating"
|
||||
@@ -436,6 +430,21 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(panel) {
|
||||
OutboundSendNotifier.progressFlow.collect { progress ->
|
||||
when (progress) {
|
||||
is OutboundSendProgress.Pending ->
|
||||
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
||||
it.copy(uploadError = null)
|
||||
}
|
||||
is OutboundSendProgress.Failed ->
|
||||
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
||||
it.copy(uploadError = progress.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(panel) {
|
||||
if (panel.getRecipientId() != null) {
|
||||
AttachmentUploadNotifier.progressFlow.collect { progress ->
|
||||
@@ -635,7 +644,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (panelState.isLoading) {
|
||||
if (panelState.isLoading && panelState.messages.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -698,12 +707,8 @@ fun ChatScreen(
|
||||
message.user_id > 0
|
||||
) {
|
||||
{
|
||||
profileSharedSourceMessageId = message.id
|
||||
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||
navController.navigate(
|
||||
"profile/${message.user_id}" +
|
||||
"?useSharedElement=true&sourceMessageId=${message.id}"
|
||||
)
|
||||
navController.navigate("profile/${message.user_id}")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -716,8 +721,6 @@ fun ChatScreen(
|
||||
isImageClosing = isImageClosing,
|
||||
showUsername = panel.showUsernamesInMessages,
|
||||
currentUserId = currentUserId,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
onCancelOutboundAttachment = { msg ->
|
||||
scope.launch { panel.cancelQueuedMessage(msg) }
|
||||
},
|
||||
@@ -731,22 +734,6 @@ fun ChatScreen(
|
||||
OutgoingMessageCoordinator.retryDmAttachmentUpload(cid)
|
||||
}
|
||||
},
|
||||
sharedAvatarNavKey =
|
||||
if (
|
||||
panel.supportsNavigateToSenderProfile &&
|
||||
sharedTransitionScope != null &&
|
||||
animatedVisibilityScope != null &&
|
||||
message.user_id != currentUserId &&
|
||||
message.user_id > 0 &&
|
||||
profileSharedSourceMessageId == message.id
|
||||
) {
|
||||
publicChatProfileSharedAvatarKey(
|
||||
message.user_id,
|
||||
message.id,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -837,6 +824,12 @@ fun ChatScreen(
|
||||
onCancelSend = { message ->
|
||||
scope.launch { panel.cancelQueuedMessage(message) }
|
||||
},
|
||||
onRetrySend = { message ->
|
||||
val cid = message.client_message_id?.trim().orEmpty()
|
||||
if (cid.isNotEmpty()) {
|
||||
OutgoingMessageCoordinator.retryOutboundMessage(cid, panel.outboxConversationId())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ fun ChatTopBarInner(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = modifier.conditional(
|
||||
profileUserId != null && onTitleClick != null
|
||||
onTitleClick != null
|
||||
) {
|
||||
Modifier.scaleOnPress(
|
||||
scale = 0.96f,
|
||||
|
||||
@@ -148,16 +148,20 @@ fun ImageFullscreenPreview(
|
||||
fileIndex = fileIndex,
|
||||
confirmed = message.id > 0,
|
||||
)
|
||||
val thumbLayoutAspect = thumbnailBounds?.takeIf { it.width > 0f && it.height > 0f }
|
||||
var animationBounds by remember(decryptCacheKey) { mutableStateOf<Rect?>(null) }
|
||||
if (animationBounds == null && thumbnailBounds != null) {
|
||||
animationBounds = thumbnailBounds
|
||||
}
|
||||
val thumbLayoutAspect = animationBounds?.takeIf { it.width > 0f && it.height > 0f }
|
||||
?.let { bounds -> bounds.width / bounds.height }
|
||||
|
||||
var menusVisible by remember { mutableStateOf(true) }
|
||||
var dismissRequested by remember { mutableStateOf(false) }
|
||||
val backgroundAlpha = remember { Animatable(1f) }
|
||||
var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) }
|
||||
var hasPlayedOpenAnimation by remember(decryptCacheKey) { mutableStateOf(false) }
|
||||
var isOpenAnimationPlaying by remember { mutableStateOf(false) }
|
||||
var dismissProgress by remember { mutableStateOf(0f) }
|
||||
val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation
|
||||
val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation
|
||||
val isTransitioning = isOpenAnimationPlaying || dismissRequested
|
||||
val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible && dismissProgress < 0.01f
|
||||
var effectiveBgAlpha by remember { mutableStateOf(1f) }
|
||||
@@ -168,8 +172,10 @@ fun ImageFullscreenPreview(
|
||||
|
||||
val systemBarsVisibility = LocalSystemBarsVisibility.current
|
||||
|
||||
LaunchedEffect(menusVisible) {
|
||||
systemBarsVisibility?.invoke(menusVisible)
|
||||
// Keep status bars visible while dismissing so ChatScreen underneath keeps
|
||||
// correct status-bar insets until the overlay is removed.
|
||||
LaunchedEffect(menusVisible, dismissRequested) {
|
||||
systemBarsVisibility?.invoke(menusVisible || dismissRequested)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
@@ -237,7 +243,9 @@ fun ImageFullscreenPreview(
|
||||
.background(Color.Black)
|
||||
)
|
||||
LaunchedEffect(dismissRequested) {
|
||||
if (dismissRequested) onDismiss()
|
||||
if (!dismissRequested) return@LaunchedEffect
|
||||
onClosingChange(true)
|
||||
onDismiss()
|
||||
}
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
@@ -255,16 +263,17 @@ fun ImageFullscreenPreview(
|
||||
contentHeightAtScale1
|
||||
}
|
||||
val initial = remember(
|
||||
thumbnailBounds, containerWidth, containerHeight, layoutContentHeight,
|
||||
animationBounds, containerWidth, containerHeight, layoutContentHeight,
|
||||
) {
|
||||
if (thumbnailBounds != null && layoutAspect != null) {
|
||||
val bounds = animationBounds
|
||||
if (bounds != null && layoutAspect != null) {
|
||||
val fullTop = (containerHeight - layoutContentHeight) / 2f
|
||||
val fullCenter = Offset(
|
||||
x = containerWidth / 2f,
|
||||
y = fullTop + layoutContentHeight / 2f,
|
||||
)
|
||||
val thumbCenter = thumbnailBounds.center
|
||||
val thumbWidth = thumbnailBounds.width
|
||||
val thumbCenter = bounds.center
|
||||
val thumbWidth = bounds.width
|
||||
val s = thumbWidth / containerWidth
|
||||
val o = thumbCenter - fullCenter
|
||||
InitialTransform(s, o.x, o.y, 12f, 0f)
|
||||
@@ -288,8 +297,9 @@ fun ImageFullscreenPreview(
|
||||
var lastStableOffsetX by remember { mutableStateOf(initial.offsetX) }
|
||||
var lastStableOffsetY by remember { mutableStateOf(initial.offsetY) }
|
||||
|
||||
LaunchedEffect(thumbnailBounds) {
|
||||
if (thumbnailBounds != null && !hasPlayedOpenAnimation) {
|
||||
LaunchedEffect(animationBounds) {
|
||||
val bounds = animationBounds ?: return@LaunchedEffect
|
||||
if (!hasPlayedOpenAnimation) {
|
||||
hasPlayedOpenAnimation = true
|
||||
isOpenAnimationPlaying = true
|
||||
|
||||
@@ -298,8 +308,8 @@ fun ImageFullscreenPreview(
|
||||
x = containerWidth / 2f,
|
||||
y = fullTop + layoutContentHeight / 2f,
|
||||
)
|
||||
val thumbCenter = thumbnailBounds.center
|
||||
val thumbWidth = thumbnailBounds.width
|
||||
val thumbCenter = bounds.center
|
||||
val thumbWidth = bounds.width
|
||||
|
||||
val startScale = thumbWidth / containerWidth
|
||||
val startOffset = thumbCenter - fullCenter
|
||||
@@ -342,12 +352,14 @@ fun ImageFullscreenPreview(
|
||||
}
|
||||
LaunchedEffect(dismissRequested) {
|
||||
if (!dismissRequested) return@LaunchedEffect
|
||||
onClosingChange(true)
|
||||
|
||||
val hasTransform = lastStableScale != 1f ||
|
||||
lastStableOffsetX != 0f ||
|
||||
lastStableOffsetY != 0f
|
||||
|
||||
if (thumbnailBounds != null) {
|
||||
val bounds = animationBounds
|
||||
if (bounds != null) {
|
||||
menusVisible = false
|
||||
val wasOpenAnimating = isOpenAnimationPlaying
|
||||
isOpenAnimationPlaying = false
|
||||
@@ -357,8 +369,8 @@ fun ImageFullscreenPreview(
|
||||
x = containerWidth / 2f,
|
||||
y = fullTop + layoutContentHeight / 2f,
|
||||
)
|
||||
val thumbCenter = thumbnailBounds.center
|
||||
val thumbWidth = thumbnailBounds.width
|
||||
val thumbCenter = bounds.center
|
||||
val thumbWidth = bounds.width
|
||||
|
||||
val targetScale = thumbWidth / containerWidth
|
||||
val targetOffset = thumbCenter - fullCenter
|
||||
@@ -395,9 +407,6 @@ fun ImageFullscreenPreview(
|
||||
}
|
||||
}
|
||||
|
||||
onClosingChange(true)
|
||||
delay(50)
|
||||
onClosingChange(false)
|
||||
onDismiss()
|
||||
}
|
||||
LaunchedEffect(scale, offset, isTransformInProgress) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.SaveAlt
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -55,6 +56,7 @@ import ru.fromchat.action_copy
|
||||
import ru.fromchat.action_delete
|
||||
import ru.fromchat.action_edit
|
||||
import ru.fromchat.action_reply
|
||||
import ru.fromchat.action_retry_send
|
||||
import ru.fromchat.action_save
|
||||
import ru.fromchat.api.local.download.resolveSavableMessageFile
|
||||
import ru.fromchat.api.local.download.resolveSavableMessageImage
|
||||
@@ -76,9 +78,11 @@ internal fun messageContextMenuFingerprint(
|
||||
isReadOnly: Boolean,
|
||||
): String {
|
||||
val isQueued = message.isQueuedOutbound() && isAuthor
|
||||
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
|
||||
val corrupted = message.isContentCorrupted
|
||||
return buildString {
|
||||
append("q=").append(isQueued)
|
||||
append("|failed=").append(sendFailed)
|
||||
append("|copy=").append(!corrupted)
|
||||
append("|save=").append(resolveSavableMessageImage(message) != null)
|
||||
if (!isQueued && !isReadOnly) {
|
||||
@@ -100,6 +104,7 @@ fun MessageContextMenu(
|
||||
onCopy: (Message) -> Unit,
|
||||
onSave: (Message) -> Unit,
|
||||
onCancelSend: (Message) -> Unit,
|
||||
onRetrySend: (Message) -> Unit = {},
|
||||
isReadOnly: Boolean = false,
|
||||
screenWidthPx: Int,
|
||||
screenHeightPx: Int,
|
||||
@@ -162,6 +167,7 @@ fun MessageContextMenu(
|
||||
onCopy = {},
|
||||
onSave = {},
|
||||
onCancelSend = {},
|
||||
onRetrySend = {},
|
||||
modifier = modifier.graphicsLayer(alpha = 0f),
|
||||
animated = false,
|
||||
withShadow = false,
|
||||
@@ -260,6 +266,10 @@ fun MessageContextMenu(
|
||||
onCancelSend(it)
|
||||
onDismiss()
|
||||
},
|
||||
onRetrySend = {
|
||||
onRetrySend(it)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = modifier,
|
||||
animated = true,
|
||||
scale = scale,
|
||||
@@ -283,6 +293,7 @@ private fun ContextMenuContent(
|
||||
onCopy: (Message) -> Unit,
|
||||
onSave: (Message) -> Unit,
|
||||
onCancelSend: (Message) -> Unit,
|
||||
onRetrySend: (Message) -> Unit,
|
||||
isReadOnly: Boolean = false,
|
||||
modifier: Modifier,
|
||||
animated: Boolean,
|
||||
@@ -330,7 +341,9 @@ private fun ContextMenuContent(
|
||||
val labelCopy = stringResource(Res.string.action_copy)
|
||||
val labelSave = stringResource(Res.string.action_save)
|
||||
val labelCancelSend = stringResource(Res.string.action_cancel_send)
|
||||
val labelRetrySend = stringResource(Res.string.action_retry_send)
|
||||
val isQueued = message.isQueuedOutbound() && isAuthor
|
||||
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
|
||||
val savableImage = resolveSavableMessageImage(message)
|
||||
val savableFile = resolveSavableMessageFile(message)
|
||||
val canSave = savableImage != null || savableFile != null
|
||||
@@ -357,7 +370,19 @@ private fun ContextMenuContent(
|
||||
onClick = { onSave(message) }
|
||||
)
|
||||
}
|
||||
if (isQueued) {
|
||||
if (sendFailed) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
text = labelRetrySend,
|
||||
onClick = { onRetrySend(message) },
|
||||
)
|
||||
ContextMenuItem(
|
||||
icon = Icons.Rounded.Close,
|
||||
text = labelCancelSend,
|
||||
onClick = { onCancelSend(message) },
|
||||
isError = true,
|
||||
)
|
||||
} else if (isQueued) {
|
||||
ContextMenuItem(
|
||||
icon = Icons.Rounded.Close,
|
||||
text = labelCancelSend,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package ru.fromchat.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
@@ -26,7 +24,10 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,6 +48,8 @@ import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.pr0gramm3r101.utils.conditional
|
||||
@@ -58,6 +61,7 @@ import ru.fromchat.api.local.messages.isQueuedOutbound
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.message_corrupted
|
||||
import ru.fromchat.message_edited_suffix
|
||||
import ru.fromchat.message_send_failed
|
||||
import ru.fromchat.ui.chat.components.getMessageGradient
|
||||
import ru.fromchat.ui.chat.components.getReplyMessageGradient
|
||||
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
||||
@@ -103,9 +107,6 @@ fun MessageItem(
|
||||
isImageClosing: Boolean = false,
|
||||
isContextMenuOpen: Boolean = false,
|
||||
isContextMenuForThisMessage: Boolean = false,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
sharedAvatarNavKey: String? = null,
|
||||
onCancelOutboundAttachment: ((Message) -> Unit)? = null,
|
||||
onRetryOutboundAttachment: ((Message) -> Unit)? = null,
|
||||
) {
|
||||
@@ -118,6 +119,7 @@ fun MessageItem(
|
||||
}
|
||||
val corruptedBody = stringResource(Res.string.message_corrupted)
|
||||
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
||||
val sendFailedLabel = stringResource(Res.string.message_send_failed)
|
||||
val displayUsername = messageDisplayUsername(message, currentUserId)
|
||||
val replyRef = message.reply_to
|
||||
|
||||
@@ -178,29 +180,11 @@ fun MessageItem(
|
||||
)
|
||||
}
|
||||
) {
|
||||
val navSharedKey = sharedAvatarNavKey
|
||||
val navStScope = sharedTransitionScope
|
||||
val navVisScope = animatedVisibilityScope
|
||||
if (navSharedKey != null && navStScope != null && navVisScope != null) {
|
||||
with(navStScope) {
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
modifier = Modifier
|
||||
.sharedElement(
|
||||
rememberSharedContentState(key = navSharedKey),
|
||||
animatedVisibilityScope = navVisScope
|
||||
)
|
||||
.size(32.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -608,13 +592,14 @@ fun MessageItem(
|
||||
|
||||
// Timestamp, sending indicator, and edited indicator
|
||||
val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty()
|
||||
val sendFailed = isPendingOutbound && !message.uploadError.isNullOrBlank()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isPendingOutbound) {
|
||||
if (isPendingOutbound && !sendFailed) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
@@ -625,6 +610,20 @@ fun MessageItem(
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
} else if (sendFailed) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ErrorOutline,
|
||||
contentDescription = sendFailedLabel,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.semantics { contentDescription = sendFailedLabel },
|
||||
tint = if (isAuthor) {
|
||||
Color.White.copy(alpha = 0.85f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
text = formattedTime,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavController
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
||||
@@ -75,9 +76,17 @@ fun DmProfileRoute(
|
||||
val stateSnapshot = panel.getState()
|
||||
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
||||
val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl
|
||||
|
||||
ProfileCache.mergePreview(
|
||||
id = otherUserId,
|
||||
displayName = initialDisplayName,
|
||||
profilePicture = initialProfilePictureUrl,
|
||||
)
|
||||
|
||||
ProfileScreen(
|
||||
userId = otherUserId,
|
||||
showBackButton = true,
|
||||
onBack = {
|
||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||
navController.popBackStack()
|
||||
@@ -90,6 +99,7 @@ fun DmProfileRoute(
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
sharedAvatarKey = sharedAvatarKey,
|
||||
initialDisplayName = initialDisplayName
|
||||
initialDisplayName = initialDisplayName,
|
||||
initialProfilePictureUrl = initialProfilePictureUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ class DmPanel(
|
||||
private var otherDisplayName: String = ""
|
||||
private var otherProfilePicture: String? = null
|
||||
private val dmEnvelopeMutex = Mutex()
|
||||
private var messagesLoaded = false
|
||||
|
||||
private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean)
|
||||
|
||||
@@ -168,16 +169,28 @@ class DmPanel(
|
||||
}
|
||||
|
||||
override suspend fun loadMessages() {
|
||||
setLoading(true)
|
||||
if (messagesLoaded) return
|
||||
messagesLoaded = true
|
||||
|
||||
runCatching { MessageRepository.ensureDmConversationRow(otherUserId) }
|
||||
|
||||
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
|
||||
// when the chat screen re-entered composition (e.g. pop back from profile).
|
||||
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
|
||||
if (cached.isNotEmpty()) {
|
||||
batchStateUpdates {
|
||||
clearMessages()
|
||||
addMessages(cached)
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
|
||||
CacheContext.requireActiveInstanceId(),
|
||||
)
|
||||
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
|
||||
if (cached.isNotEmpty()) {
|
||||
clearMessages()
|
||||
addMessages(cached)
|
||||
}
|
||||
|
||||
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
|
||||
if (historyResult.isSuccess) {
|
||||
@@ -512,9 +525,11 @@ class DmPanel(
|
||||
deleteMessageImmediately(messageId)
|
||||
DownloadedFileRegistry.invalidateForMessage(messageId)
|
||||
DecryptedImageCache.invalidateForMessage(messageId)
|
||||
DecryptedFileCache.invalidateForMessage(messageId)
|
||||
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
DownloadedFileRegistry.invalidateForClientMessage(it)
|
||||
DecryptedImageCache.invalidateForClientMessage(it)
|
||||
DecryptedFileCache.invalidateForClientMessage(it)
|
||||
}
|
||||
runCatching { ApiClient.deleteDm(messageId, otherUserId) }
|
||||
withContext(Dispatchers.Default) {
|
||||
@@ -535,9 +550,11 @@ class DmPanel(
|
||||
val clientId = _state.messages.find { it.id == data.id }?.client_message_id
|
||||
DownloadedFileRegistry.invalidateForMessage(data.id)
|
||||
DecryptedImageCache.invalidateForMessage(data.id)
|
||||
DecryptedFileCache.invalidateForMessage(data.id)
|
||||
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
DownloadedFileRegistry.invalidateForClientMessage(it)
|
||||
DecryptedImageCache.invalidateForClientMessage(it)
|
||||
DecryptedFileCache.invalidateForClientMessage(it)
|
||||
}
|
||||
deleteMessageImmediately(data.id)
|
||||
MessageRepository.deleteDmMessageById(otherUserId, data.id)
|
||||
|
||||
@@ -36,8 +36,13 @@ fun DmScreen(
|
||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||
val otherUserId = panel.getState().profileUserId
|
||||
|
||||
LaunchedEffect(panel, otherUserId) {
|
||||
if (otherUserId != null && otherUserId > 0) {
|
||||
LaunchedEffect(panel, activeInstanceId, otherUserId) {
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
val peerId = otherUserId ?: return@LaunchedEffect
|
||||
if (peerId <= 0) return@LaunchedEffect
|
||||
// Panel is retained in [DmPanelCache]; only cold-load when the list is still empty
|
||||
// (e.g. returning from profile must not call loadMessages and flash the chat spinner).
|
||||
if (panel.getState().messages.isEmpty()) {
|
||||
panel.loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package ru.fromchat.ui.chat.panels.publicchat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavController
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
import ru.fromchat.ui.profile.PublicChatProfileScreen
|
||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
||||
|
||||
/** Route patterns for public chat + profile (stacked for predictive / system back). */
|
||||
object PublicChatNav {
|
||||
const val CHAT_ROUTE = "chats/publicChat"
|
||||
const val PROFILE_ROUTE = "chats/publicChat/profile"
|
||||
|
||||
const val SHARED_HEADER_KEY = "public-chat-header"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PublicChatChatRoute(
|
||||
scrollToMessageId: Int? = null,
|
||||
navController: NavController,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val haptic = rememberHapticFeedback()
|
||||
|
||||
PublicChatScreen(
|
||||
scrollToMessageId = scrollToMessageId,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
|
||||
onTitleClick = {
|
||||
haptic(HapticFeedbackEvent.ProfileOpened)
|
||||
navController.navigate(PublicChatNav.PROFILE_ROUTE)
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PublicChatProfileRoute(
|
||||
navController: NavController,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val currentUserId = ApiClient.user?.id
|
||||
val panel = remember(currentUserId) {
|
||||
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
|
||||
}
|
||||
val haptic = rememberHapticFeedback()
|
||||
val stateSnapshot = panel.getState()
|
||||
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
||||
?: PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
|
||||
|
||||
PublicChatProfileScreen(
|
||||
showBackButton = true,
|
||||
onBack = {
|
||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||
navController.popBackStack()
|
||||
},
|
||||
onChat = {
|
||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||
navController.popBackStack()
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
|
||||
initialDisplayName = initialDisplayName,
|
||||
)
|
||||
}
|
||||
+23
-17
@@ -11,10 +11,12 @@ import ru.fromchat.Logger
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
|
||||
import ru.fromchat.api.local.messages.conversationIdForGroup
|
||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse
|
||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||
@@ -30,8 +32,6 @@ import ru.fromchat.ui.chat.utils.TypingHandler
|
||||
class PublicChatPanel(
|
||||
/** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */
|
||||
panelKey: String,
|
||||
/** Shown in the app bar and avatars. */
|
||||
displayTitle: String,
|
||||
currentUserId: Int?,
|
||||
scope: CoroutineScope
|
||||
) : ChatPanel(
|
||||
@@ -70,8 +70,8 @@ class PublicChatPanel(
|
||||
init {
|
||||
updateState {
|
||||
it.copy(
|
||||
title = displayTitle,
|
||||
titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null),
|
||||
title = "",
|
||||
titleAvatar = null,
|
||||
publicGroupMetaLoading = true,
|
||||
publicGroupMemberCount = null
|
||||
)
|
||||
@@ -83,27 +83,30 @@ class PublicChatPanel(
|
||||
}
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
runCatching { ApiClient.getRegisteredUserCount() }
|
||||
.onSuccess { n ->
|
||||
updateState { s ->
|
||||
s.copy(publicGroupMemberCount = n, publicGroupMetaLoading = false)
|
||||
}
|
||||
val cached = PublicChatProfileCache.profile
|
||||
if (cached != null) {
|
||||
applyPublicChatProfile(cached)
|
||||
}
|
||||
runCatching { ApiClient.getPublicChatProfile() }
|
||||
.onSuccess { profile ->
|
||||
PublicChatProfileCache.put(profile)
|
||||
applyPublicChatProfile(profile)
|
||||
}
|
||||
.onFailure {
|
||||
updateState { s -> s.copy(publicGroupMetaLoading = false) }
|
||||
if (cached == null) {
|
||||
updateState { s -> s.copy(publicGroupMetaLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When locale changes, keep the same panel but refresh the visible title. */
|
||||
fun applyDisplayTitle(title: String) {
|
||||
private fun applyPublicChatProfile(profile: PublicChatProfile) {
|
||||
updateState { s ->
|
||||
s.copy(
|
||||
title = title,
|
||||
titleAvatar = AvatarInfo(
|
||||
displayName = title,
|
||||
profilePictureUrl = s.titleAvatar?.profilePictureUrl
|
||||
)
|
||||
title = profile.title,
|
||||
titleAvatar = AvatarInfo(displayName = profile.title, profilePictureUrl = null),
|
||||
publicGroupMemberCount = profile.member_count,
|
||||
publicGroupMetaLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -353,6 +356,9 @@ class PublicChatPanel(
|
||||
val obj = data.jsonObject
|
||||
val c = obj["count"]?.jsonPrimitive?.content?.toIntOrNull()
|
||||
if (c != null) {
|
||||
PublicChatProfileCache.profile?.let { cached ->
|
||||
PublicChatProfileCache.put(cached.copy(member_count = c))
|
||||
}
|
||||
updateState { s ->
|
||||
s.copy(publicGroupMemberCount = c, publicGroupMetaLoading = false)
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,19 +1,18 @@
|
||||
package ru.fromchat.ui.chat.panels.publicchat
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import androidx.compose.ui.Modifier
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.public_chat
|
||||
import ru.fromchat.ui.chat.ChatScreen
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
|
||||
@@ -21,14 +20,15 @@ import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
fun PublicChatScreen(
|
||||
scrollToMessageId: Int? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
sharedAvatarKey: Any? = null,
|
||||
onTitleClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val currentUserId = ApiClient.user?.id
|
||||
val publicChatTitle = stringResource(Res.string.public_chat)
|
||||
|
||||
// Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit.
|
||||
val panel = remember(currentUserId, publicChatTitle) {
|
||||
PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
|
||||
val panel = remember(currentUserId) {
|
||||
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
|
||||
}
|
||||
|
||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||
@@ -47,7 +47,6 @@ fun PublicChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Track visibility for notifications
|
||||
DisposableEffect(Unit) {
|
||||
isPublicChatVisible = true
|
||||
onDispose {
|
||||
@@ -55,14 +54,15 @@ fun PublicChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Render with ChatScreen
|
||||
ChatScreen(
|
||||
panel = panel,
|
||||
currentUserId = currentUserId,
|
||||
scrollToMessageId = scrollToMessageId,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
sharedAvatarKey = null,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
sharedAvatarKey = sharedAvatarKey,
|
||||
onTitleClick = onTitleClick,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,4 +74,4 @@ fun PublicChatScreen(
|
||||
fun publicChatProfileSharedAvatarKey(userId: Int, sourceMessageId: Int): String =
|
||||
"public-chat-profile-avatar-$userId-$sourceMessageId"
|
||||
|
||||
var isPublicChatVisible = false
|
||||
var isPublicChatVisible = false
|
||||
|
||||
@@ -24,7 +24,6 @@ object PublicChatPanelCache {
|
||||
|
||||
private var panel: PublicChatPanel? = null
|
||||
private var cachedPanelKey: String? = null
|
||||
private var cachedDisplayTitle: String? = null
|
||||
private var cachedUserId: Int? = null
|
||||
private var cachedInstanceId: String = ""
|
||||
|
||||
@@ -42,10 +41,7 @@ object PublicChatPanelCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat].
|
||||
*/
|
||||
fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
|
||||
fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel {
|
||||
ensureScope()
|
||||
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||
if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) {
|
||||
@@ -58,21 +54,15 @@ object PublicChatPanelCache {
|
||||
cachedUserId == currentUserId &&
|
||||
(instanceId.isEmpty() || cachedInstanceId == instanceId)
|
||||
) {
|
||||
if (cachedDisplayTitle != displayTitle) {
|
||||
cachedDisplayTitle = displayTitle
|
||||
panel!!.applyDisplayTitle(displayTitle)
|
||||
}
|
||||
return panel!!
|
||||
}
|
||||
panel?.destroy()
|
||||
panel = PublicChatPanel(
|
||||
panelKey = GeneralPublicPanelKey,
|
||||
displayTitle = displayTitle,
|
||||
currentUserId = currentUserId,
|
||||
scope = panelScope
|
||||
)
|
||||
cachedPanelKey = GeneralPublicPanelKey
|
||||
cachedDisplayTitle = displayTitle
|
||||
cachedUserId = currentUserId
|
||||
return panel!!
|
||||
}
|
||||
@@ -81,8 +71,6 @@ object PublicChatPanelCache {
|
||||
panel?.destroy()
|
||||
panel = null
|
||||
cachedPanelKey = null
|
||||
cachedDisplayTitle = null
|
||||
cachedUserId = null
|
||||
supervisorJob.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+149
-139
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Мне лень чистить этот файл, я устал.
|
||||
* TODO Почищу когда-нибудь потом.
|
||||
*/
|
||||
package ru.fromchat.ui.components
|
||||
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
@@ -344,7 +348,6 @@ fun ExpressiveStepFlowScaffold(
|
||||
val scope = flowState.scope
|
||||
val pageCount = pages.size.coerceAtLeast(1)
|
||||
val heroSpecs = remember(pages) { pages.map { it.hero } }
|
||||
val isHazeLazyMode = hazeScaffold
|
||||
val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction }
|
||||
val predictiveThreshold = 0.15f
|
||||
|
||||
@@ -352,6 +355,7 @@ fun ExpressiveStepFlowScaffold(
|
||||
enabled = pagerState.currentPage > 0,
|
||||
onProgress = { p ->
|
||||
val clamped = p.coerceIn(0f, 1f)
|
||||
|
||||
if (clamped <= 0f) {
|
||||
flowState.resetPredictiveState()
|
||||
} else {
|
||||
@@ -368,6 +372,7 @@ fun ExpressiveStepFlowScaffold(
|
||||
val fromPageSnapshot = flowState.predictiveFromPage
|
||||
val toPageSnapshot = flowState.predictiveToPage
|
||||
val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f)
|
||||
|
||||
if (lastProgress < predictiveThreshold || fromPageSnapshot == null || toPageSnapshot == null) {
|
||||
scope.launch {
|
||||
finishPredictiveMorph(
|
||||
@@ -426,6 +431,7 @@ fun ExpressiveStepFlowScaffold(
|
||||
val fromIndex: Int
|
||||
val toIndex: Int
|
||||
val morphProgress: Float
|
||||
|
||||
if (flowState.predictiveFromPage != null && flowState.predictiveToPage != null && flowState.predictiveProgress > 0f) {
|
||||
fromIndex = flowState.predictiveFromPage!!
|
||||
toIndex = flowState.predictiveToPage!!
|
||||
@@ -443,6 +449,7 @@ fun ExpressiveStepFlowScaffold(
|
||||
toIndex = fromIndex
|
||||
morphProgress = 0f
|
||||
}
|
||||
|
||||
val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f)
|
||||
val currentPage = pagerState.currentPage
|
||||
val morphing = fromIndex != toIndex
|
||||
@@ -592,65 +599,139 @@ fun ExpressiveStepFlowScaffold(
|
||||
LocalExpressiveStepFocusEnabled provides isPageTransitionSettled,
|
||||
LocalExpressiveStepAutoFocusPrimary provides autoFocusPrimaryField,
|
||||
) {
|
||||
if (isHazeLazyMode) {
|
||||
val hazeState = rememberHazeState()
|
||||
val listState = rememberLazyListState()
|
||||
val imeScrollState = rememberLazyListImeScrollState()
|
||||
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
if (hazeScaffold) {
|
||||
val hazeState = rememberHazeState()
|
||||
val listState = rememberLazyListState()
|
||||
val imeScrollState = rememberLazyListImeScrollState()
|
||||
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = scheme.onSurface,
|
||||
topBar = { HazeTopBar(hazeState = hazeState) },
|
||||
bottomBar = {
|
||||
HazeBottomBar(hazeState = hazeState) {
|
||||
Box(
|
||||
Modifier.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
|
||||
),
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = scheme.onSurface,
|
||||
topBar = { HazeTopBar(hazeState = hazeState) },
|
||||
bottomBar = {
|
||||
HazeBottomBar(hazeState = hazeState) {
|
||||
Box(
|
||||
Modifier.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
|
||||
),
|
||||
) {
|
||||
ExpressiveStepBottomBar()
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyListImeScrollEffect(
|
||||
listState = listState,
|
||||
scrollState = imeScrollState,
|
||||
viewportBoundsInWindow = listViewportBounds,
|
||||
contentPaddingTop = innerPadding.calculateTopPadding(),
|
||||
contentPaddingBottom = innerPadding.calculateBottomPadding(),
|
||||
predictiveBackProgress = { flowState.predictiveProgress },
|
||||
)
|
||||
|
||||
DisabledBringIntoViewSpec {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.hazeSource(hazeState)
|
||||
.onGloballyPositioned {
|
||||
listViewportBounds = it.boundsInWindow()
|
||||
listViewportBoundsForSnackbar = it.boundsInWindow()
|
||||
},
|
||||
contentPadding = innerPadding,
|
||||
verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) },
|
||||
) {
|
||||
ExpressiveStepBottomBar()
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
|
||||
item {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ExpressiveStepHeroSection()
|
||||
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = false,
|
||||
beyondViewportPageCount = 1,
|
||||
pageSpacing = 0.dp,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { page ->
|
||||
Column(modifier = Modifier.pagerPageFullWidth()) {
|
||||
pages[page].content(imeScrollState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentListFooter = pages.getOrNull(currentPage)?.listFooter
|
||||
if (currentListFooter != null) {
|
||||
item(key = "expressive_step_footer_$currentPage") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 4.dp)
|
||||
.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.SecondaryCta,
|
||||
),
|
||||
) {
|
||||
currentListFooter()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top.
|
||||
item(key = "expressive_step_bottom_anchor") {
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyListImeScrollEffect(
|
||||
listState = listState,
|
||||
scrollState = imeScrollState,
|
||||
viewportBoundsInWindow = listViewportBounds,
|
||||
contentPaddingTop = innerPadding.calculateTopPadding(),
|
||||
contentPaddingBottom = innerPadding.calculateBottomPadding(),
|
||||
predictiveBackProgress = { flowState.predictiveProgress },
|
||||
)
|
||||
}
|
||||
|
||||
DisabledBringIntoViewSpec {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.hazeSource(hazeState)
|
||||
.onGloballyPositioned {
|
||||
listViewportBounds = it.boundsInWindow()
|
||||
listViewportBoundsForSnackbar = it.boundsInWindow()
|
||||
},
|
||||
contentPadding = innerPadding,
|
||||
verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) },
|
||||
) {
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
ExpressiveStepSnackbarHost()
|
||||
}
|
||||
} else {
|
||||
val imeScrollState = rememberLazyListImeScrollState()
|
||||
|
||||
item {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = scheme.surface,
|
||||
contentColor = scheme.onSurface,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
BackButtonRow()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ExpressiveStepHeroSection()
|
||||
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = false,
|
||||
@@ -659,108 +740,35 @@ fun ExpressiveStepFlowScaffold(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { page ->
|
||||
Column(modifier = Modifier.pagerPageFullWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.pagerPageFullWidth(),
|
||||
) {
|
||||
pages[page].content(imeScrollState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentListFooter = pages.getOrNull(currentPage)?.listFooter
|
||||
if (currentListFooter != null) {
|
||||
item(key = "expressive_step_footer_$currentPage") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 4.dp)
|
||||
.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.SecondaryCta,
|
||||
),
|
||||
) {
|
||||
currentListFooter()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top.
|
||||
item(key = "expressive_step_bottom_anchor") {
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExpressiveStepSnackbarHost()
|
||||
}
|
||||
} else {
|
||||
val imeScrollState = rememberLazyListImeScrollState()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = scheme.surface,
|
||||
contentColor = scheme.onSurface,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
BackButtonRow()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(top = 12.dp, bottom = 16.dp)
|
||||
.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
|
||||
),
|
||||
) {
|
||||
ExpressiveStepHeroSection()
|
||||
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = false,
|
||||
beyondViewportPageCount = 1,
|
||||
pageSpacing = 0.dp,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { page ->
|
||||
Column(
|
||||
modifier = Modifier.pagerPageFullWidth(),
|
||||
) {
|
||||
pages[page].content(imeScrollState)
|
||||
}
|
||||
}
|
||||
ExpressiveStepBottomBar()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(top = 12.dp, bottom = 16.dp)
|
||||
.trackExpressiveStepSnackbarAnchor(
|
||||
anchors = snackbarAnchors,
|
||||
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
|
||||
),
|
||||
) {
|
||||
ExpressiveStepBottomBar()
|
||||
}
|
||||
ExpressiveStepSnackbarHost()
|
||||
}
|
||||
|
||||
ExpressiveStepSnackbarHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class)
|
||||
@@ -791,6 +799,7 @@ fun MorphedExpressiveHero(
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val unit = minOf(size.width, size.height) * 0.94f
|
||||
|
||||
translate(left = size.width / 2f, top = size.height / 2f) {
|
||||
scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) {
|
||||
translate(left = -0.5f, top = -0.5f) {
|
||||
@@ -800,7 +809,7 @@ fun MorphedExpressiveHero(
|
||||
colors = listOf(light, deep),
|
||||
start = Offset.Zero,
|
||||
end = Offset(1f, 1f),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -820,6 +829,7 @@ fun MorphedExpressiveHero(
|
||||
.graphicsLayer { alpha = 1f - p },
|
||||
tint = contentColor,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = toSpec.icon,
|
||||
contentDescription = null,
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
@@ -24,18 +25,24 @@ import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
fun HazeBottomBar(
|
||||
hazeState: HazeState,
|
||||
modifier: Modifier = Modifier,
|
||||
hazeStyle: HazeStyle? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val effectModifier = if (hazeStyle != null) {
|
||||
Modifier.hazeEffect(state = hazeState, style = hazeStyle)
|
||||
} else {
|
||||
Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 0f,
|
||||
endIntensity = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
.fillMaxWidth()
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 0f,
|
||||
endIntensity = 1f,
|
||||
)
|
||||
}
|
||||
.then(effectModifier)
|
||||
.then(modifier),
|
||||
) {
|
||||
Column(
|
||||
@@ -57,12 +64,13 @@ fun HazeActionButton(
|
||||
hazeState: HazeState,
|
||||
modifier: Modifier = Modifier,
|
||||
innerModifier: Modifier = Modifier,
|
||||
hazeStyle: HazeStyle? = null,
|
||||
enabled: Boolean = true,
|
||||
loading: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable (RowScope.() -> Unit)
|
||||
) {
|
||||
HazeBottomBar(hazeState = hazeState, modifier = modifier) {
|
||||
HazeBottomBar(hazeState = hazeState, modifier = modifier, hazeStyle = hazeStyle) {
|
||||
ActionButton(
|
||||
onClick = onClick,
|
||||
modifier = innerModifier,
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -63,16 +62,6 @@ fun SearchBar(
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.conditional(
|
||||
readOnly && onReadOnlyActivate != null,
|
||||
`if` = {
|
||||
clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onReadOnlyActivate!!
|
||||
)
|
||||
}
|
||||
)
|
||||
.conditional(
|
||||
(
|
||||
sharedTransitionScope != null &&
|
||||
@@ -98,49 +87,78 @@ fun SearchBar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
.padding(horizontal = 14.dp)
|
||||
.conditional(
|
||||
readOnly && onReadOnlyActivate != null,
|
||||
`if` = {
|
||||
clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onReadOnlyActivate!!,
|
||||
)
|
||||
},
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(24.dp)) {
|
||||
leadingIcon()
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = typography.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
enabled = !readOnly,
|
||||
readOnly = readOnly,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { onSearch() }
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
if (readOnly) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(start = 12.dp, end = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = query.ifEmpty { placeholder },
|
||||
style = typography.copy(
|
||||
color = if (query.isEmpty()) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = typography.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { onSearch() }
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.width(6.dp))
|
||||
Box(modifier = Modifier.size(24.dp)) {
|
||||
|
||||
@@ -2,13 +2,18 @@ package ru.fromchat.ui.main
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -18,15 +23,27 @@ import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
@@ -36,7 +53,10 @@ import ru.fromchat.contacts
|
||||
import ru.fromchat.profile
|
||||
import ru.fromchat.settings
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
|
||||
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
|
||||
import ru.fromchat.ui.main.chats.ChatsTab
|
||||
import ru.fromchat.ui.main.settings.SettingsTab
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
@@ -48,7 +68,7 @@ private const val PAGE_SETTINGS = 2
|
||||
private const val PAGE_PROFILE = 3
|
||||
private const val PAGE_COUNT = 4
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
@@ -62,87 +82,163 @@ fun MainScreen(
|
||||
pageCount = { PAGE_COUNT },
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val navBarHazeState = rememberHazeState(blurEnabled = true)
|
||||
val contextMenuHazeState = rememberHazeState(blurEnabled = true)
|
||||
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
||||
|
||||
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
|
||||
val selectedPage = pagerState.currentPage
|
||||
val isChatsPage = selectedPage == PAGE_CHATS
|
||||
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_CHATS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.chats)) },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_CONTACTS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.contacts)) },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_SETTINGS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.settings)) },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_PROFILE,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.profile)) },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
||||
modifier = Modifier.imePadding()
|
||||
) { innerPadding ->
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.hazeSource(contextMenuHazeState),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
beyondViewportPageCount = 1,
|
||||
) { page ->
|
||||
when (page) {
|
||||
PAGE_CHATS -> ChatsTab(
|
||||
isVisible = isChatsPage,
|
||||
onOpenSearch = {
|
||||
navController.navigate("search/conversations")
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
PAGE_CONTACTS -> ContactsTab()
|
||||
PAGE_SETTINGS -> SettingsTab()
|
||||
PAGE_PROFILE -> {
|
||||
ProfileScreen(
|
||||
userId = ApiClient.user?.id,
|
||||
onBack = {},
|
||||
onChat = { _ -> },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenSettings = {
|
||||
Scaffold(
|
||||
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.hazeEffect(
|
||||
state = navBarHazeState,
|
||||
style = rememberChatSurfaceContainerHazeStyle(),
|
||||
),
|
||||
) {
|
||||
NavigationBar(
|
||||
containerColor = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_CHATS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.chats)) },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_CONTACTS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.contacts)) },
|
||||
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_SETTINGS,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(Res.string.settings)) },
|
||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedPage == PAGE_PROFILE,
|
||||
onClick = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
|
||||
},
|
||||
label = { Text(stringResource(Res.string.profile)) },
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
|
||||
modifier = Modifier.imePadding(),
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(navBarHazeState),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
beyondViewportPageCount = 1,
|
||||
) { page ->
|
||||
when (page) {
|
||||
PAGE_CHATS -> ChatsTab(
|
||||
isVisible = isChatsPage,
|
||||
onOpenSearch = {
|
||||
navController.navigate("search/conversations")
|
||||
},
|
||||
chatContextMenuOverlay = chatContextMenuOverlay,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
PAGE_CONTACTS -> ContactsTab()
|
||||
PAGE_SETTINGS -> SettingsTab()
|
||||
PAGE_PROFILE -> {
|
||||
ProfileScreen(
|
||||
userId = ApiClient.user?.id,
|
||||
onBack = {},
|
||||
onChat = { _ -> },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenSettings = {
|
||||
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chatMenuBlurProgress > 0f) {
|
||||
ChatContextMenuBlurLayer(
|
||||
hazeState = contextMenuHazeState,
|
||||
blurProgress = chatMenuBlurProgress,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(1f),
|
||||
)
|
||||
}
|
||||
|
||||
ChatContextMenuOverlayHost(
|
||||
controller = chatContextMenuOverlay,
|
||||
screenWidthPx = constraints.maxWidth,
|
||||
screenHeightPx = constraints.maxHeight,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
private fun ChatContextMenuBlurLayer(
|
||||
hazeState: HazeState,
|
||||
blurProgress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blurRadius = 12.dp * blurProgress
|
||||
if (blurRadius <= 0.dp) return
|
||||
|
||||
Box(
|
||||
modifier = modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = HazeStyle(
|
||||
blurRadius = blurRadius,
|
||||
tints = emptyList(),
|
||||
backgroundColor = Color.Transparent,
|
||||
noiseFactor = 0f,
|
||||
fallbackTint = HazeTint(Color.Transparent),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package ru.fromchat.ui.main.chats
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.rounded.Call
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.MarkEmailRead
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.scaleOnPress
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.action_delete
|
||||
import ru.fromchat.action_mark_read
|
||||
import ru.fromchat.action_select
|
||||
import ru.fromchat.profile_action_call
|
||||
import ru.fromchat.profile_action_chat
|
||||
import ru.fromchat.profile_action_link
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
@Composable
|
||||
internal fun ChatContextMenuMeasurer(
|
||||
state: ChatContextMenuState,
|
||||
listFilter: ChatListFilter,
|
||||
callsEnabled: Boolean,
|
||||
dmUnreadCount: Int,
|
||||
showPublicMarkRead: Boolean,
|
||||
hasPublicLink: Boolean,
|
||||
isReadOnly: Boolean,
|
||||
screenWidthPx: Int,
|
||||
screenHeightPx: Int,
|
||||
onMeasured: (IntSize) -> Unit,
|
||||
) {
|
||||
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
||||
val looseConstraints = Constraints(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxWidth = screenWidthPx,
|
||||
maxHeight = screenHeightPx,
|
||||
)
|
||||
val placeables = subcompose("measure") {
|
||||
ChatContextMenuContent(
|
||||
state = state,
|
||||
listFilter = listFilter,
|
||||
callsEnabled = callsEnabled,
|
||||
dmUnreadCount = dmUnreadCount,
|
||||
showPublicMarkRead = showPublicMarkRead,
|
||||
hasPublicLink = hasPublicLink,
|
||||
onMessage = {},
|
||||
onCall = {},
|
||||
onLink = {},
|
||||
onMarkRead = {},
|
||||
onDelete = {},
|
||||
onSelect = {},
|
||||
isReadOnly = isReadOnly,
|
||||
modifier = Modifier.graphicsLayer(alpha = 0f),
|
||||
withShadow = false,
|
||||
)
|
||||
}.map { it.measure(looseConstraints) }
|
||||
val measured = placeables.firstOrNull()?.let { IntSize(it.width, it.height) } ?: IntSize.Zero
|
||||
if (measured != IntSize.Zero) {
|
||||
onMeasured(measured)
|
||||
}
|
||||
layout(0, 0) {
|
||||
placeables.forEach { it.placeRelative(-10000, -10000) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChatContextMenuPanel(
|
||||
state: ChatContextMenuState,
|
||||
listFilter: ChatListFilter,
|
||||
callsEnabled: Boolean,
|
||||
dmUnreadCount: Int,
|
||||
showPublicMarkRead: Boolean,
|
||||
hasPublicLink: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMessage: () -> Unit,
|
||||
onCall: () -> Unit,
|
||||
onLink: () -> Unit,
|
||||
onMarkRead: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
isReadOnly: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
scale: Float = 1f,
|
||||
alpha: Float = 1f,
|
||||
cornerRadius: Dp = 16.dp,
|
||||
) {
|
||||
if (isReadOnly) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
ChatContextMenuContent(
|
||||
state = state,
|
||||
listFilter = listFilter,
|
||||
callsEnabled = callsEnabled,
|
||||
dmUnreadCount = dmUnreadCount,
|
||||
showPublicMarkRead = showPublicMarkRead,
|
||||
hasPublicLink = hasPublicLink,
|
||||
onMessage = {
|
||||
onMessage()
|
||||
onDismiss()
|
||||
},
|
||||
onCall = {
|
||||
onCall()
|
||||
onDismiss()
|
||||
},
|
||||
onLink = {
|
||||
onLink()
|
||||
onDismiss()
|
||||
},
|
||||
onMarkRead = {
|
||||
onMarkRead()
|
||||
onDismiss()
|
||||
},
|
||||
onDelete = {
|
||||
onDelete()
|
||||
onDismiss()
|
||||
},
|
||||
onSelect = onSelect,
|
||||
isReadOnly = isReadOnly,
|
||||
scale = scale,
|
||||
alpha = alpha,
|
||||
cornerRadius = cornerRadius,
|
||||
transformOriginX = 0f,
|
||||
transformOriginY = 0f,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChatContextMenuContent(
|
||||
state: ChatContextMenuState,
|
||||
listFilter: ChatListFilter,
|
||||
callsEnabled: Boolean,
|
||||
dmUnreadCount: Int,
|
||||
showPublicMarkRead: Boolean,
|
||||
hasPublicLink: Boolean,
|
||||
onMessage: () -> Unit,
|
||||
onCall: () -> Unit,
|
||||
onLink: () -> Unit,
|
||||
onMarkRead: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
isReadOnly: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
withShadow: Boolean = true,
|
||||
scale: Float = 1f,
|
||||
alpha: Float = 1f,
|
||||
cornerRadius: Dp = 16.dp,
|
||||
transformOriginX: Float = 0.5f,
|
||||
transformOriginY: Float = 0f,
|
||||
) {
|
||||
val menuShape = RoundedCornerShape(cornerRadius)
|
||||
val menuScrollState = rememberScrollState()
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val shadowElevationPx = if (withShadow) {
|
||||
with(density) { 12.dp.toPx() }
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
val containerModifier = modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
alpha = alpha,
|
||||
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
|
||||
shadowElevation = shadowElevationPx,
|
||||
shape = menuShape,
|
||||
clip = true,
|
||||
)
|
||||
|
||||
val menuColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val labelMessage = stringResource(Res.string.profile_action_chat)
|
||||
val labelCall = stringResource(Res.string.profile_action_call)
|
||||
val labelLink = stringResource(Res.string.profile_action_link)
|
||||
val labelMarkRead = stringResource(Res.string.action_mark_read)
|
||||
val labelDelete = stringResource(Res.string.action_delete)
|
||||
val labelSelect = stringResource(Res.string.action_select)
|
||||
|
||||
Box(modifier = containerModifier) {
|
||||
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.verticalScroll(menuScrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (!isReadOnly) {
|
||||
when (state.target) {
|
||||
ChatContextMenuTarget.Public -> {
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
text = labelMessage,
|
||||
onClick = onMessage,
|
||||
)
|
||||
if (showPublicMarkRead) {
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Rounded.MarkEmailRead,
|
||||
text = labelMarkRead,
|
||||
onClick = onMarkRead,
|
||||
)
|
||||
}
|
||||
if (hasPublicLink) {
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Filled.Link,
|
||||
text = labelLink,
|
||||
onClick = onLink,
|
||||
)
|
||||
}
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
text = labelSelect,
|
||||
onClick = onSelect,
|
||||
)
|
||||
}
|
||||
|
||||
ChatContextMenuTarget.Dm -> {
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
text = labelMessage,
|
||||
onClick = onMessage,
|
||||
)
|
||||
if (callsEnabled) {
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Rounded.Call,
|
||||
text = labelCall,
|
||||
onClick = onCall,
|
||||
)
|
||||
}
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Filled.Link,
|
||||
text = labelLink,
|
||||
onClick = onLink,
|
||||
)
|
||||
if (dmUnreadCount > 0) {
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Rounded.MarkEmailRead,
|
||||
text = labelMarkRead,
|
||||
onClick = onMarkRead,
|
||||
)
|
||||
}
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
text = labelSelect,
|
||||
onClick = onSelect,
|
||||
)
|
||||
ChatContextMenuItem(
|
||||
icon = Icons.Rounded.Delete,
|
||||
text = labelDelete,
|
||||
onClick = onDelete,
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatContextMenuItem(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val contentColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.scaleOnPress(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = contentColor,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
package ru.fromchat.ui.main.chats
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ripple
|
||||
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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.Category
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import com.pr0gramm3r101.components.ListItemPosition
|
||||
import com.pr0gramm3r101.components.listItemClipShape
|
||||
import com.pr0gramm3r101.components.listItemPositionInGroup
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.CachedConversation
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.UserStatus
|
||||
import ru.fromchat.api.local.db.store.visibleUsername
|
||||
import ru.fromchat.api.schema.user.User
|
||||
import ru.fromchat.cd_chat_selected
|
||||
import ru.fromchat.presence_online
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.chat.TypingIndicator
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.unread_count
|
||||
import ru.fromchat.user_fallback
|
||||
|
||||
internal object ChatListLayout {
|
||||
private const val CATEGORY_TOP_SPACER = 0
|
||||
const val PUBLIC_CHAT_ROW = CATEGORY_TOP_SPACER + 1
|
||||
|
||||
fun dmRow(dmIndex: Int): Int = PUBLIC_CHAT_ROW + 1 + dmIndex
|
||||
|
||||
fun dmIndexFromLazy(lazyIndex: Int): Int? =
|
||||
lazyIndex.takeIf { it >= PUBLIC_CHAT_ROW + 1 }?.minus(PUBLIC_CHAT_ROW + 1)
|
||||
}
|
||||
|
||||
internal object SearchListIndices {
|
||||
private const val FIRST_RESULT_ROW = 1
|
||||
|
||||
fun resultRow(resultIndex: Int): Int = FIRST_RESULT_ROW + resultIndex
|
||||
|
||||
fun resultIndexFromLazy(lazyIndex: Int): Int? =
|
||||
lazyIndex.takeIf { it >= FIRST_RESULT_ROW }?.minus(FIRST_RESULT_ROW)
|
||||
}
|
||||
|
||||
private val ChatListCategoryMargin = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 16.dp,
|
||||
bottom = 20.dp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ChatListItemSpacer() {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChatConversationsList(
|
||||
listState: LazyListState,
|
||||
listFilter: ChatListFilter,
|
||||
conversations: List<CachedConversation>,
|
||||
publicChatTitle: String?,
|
||||
publicLastMessagePreview: String?,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
listMode: ChatsListMode,
|
||||
selectionTransitionProgress: Float,
|
||||
publicChatSelected: Boolean,
|
||||
selectedOtherUserIds: Set<Int>,
|
||||
contextMenuState: ChatContextMenuState,
|
||||
overlayCloneReady: Boolean,
|
||||
rowRevealProgress: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenPublic: () -> Unit,
|
||||
onOpenConversation: (Int) -> Unit,
|
||||
onAvatarContextMenuPressStart: (
|
||||
lazyIndex: Int,
|
||||
target: ChatContextMenuTarget,
|
||||
userId: Int?,
|
||||
rowOffset: Offset,
|
||||
rowSize: IntSize,
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
) -> Unit,
|
||||
onAvatarContextMenuPressEnd: () -> Unit,
|
||||
onAvatarContextMenuOpen: (
|
||||
lazyIndex: Int,
|
||||
target: ChatContextMenuTarget,
|
||||
userId: Int?,
|
||||
menuPosition: Offset,
|
||||
rowOffset: Offset,
|
||||
rowSize: IntSize,
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
) -> Unit,
|
||||
onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit,
|
||||
onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit,
|
||||
) {
|
||||
val scrollBlocked = contextMenuState.isOverlayActive
|
||||
val showPublicChat = listFilter == ChatListFilter.Active
|
||||
val groupCount = (if (showPublicChat) 1 else 0) + conversations.size
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
userScrollEnabled = !scrollBlocked,
|
||||
contentPadding = PaddingValues(bottom = 12.dp),
|
||||
) {
|
||||
if (groupCount > 0) {
|
||||
Category(
|
||||
margin = ChatListCategoryMargin,
|
||||
containerColor = Color.Transparent,
|
||||
roundedCorners = false,
|
||||
) {
|
||||
if (showPublicChat) {
|
||||
item {
|
||||
val position = listItemPositionInGroup(0, groupCount)
|
||||
PublicChatRow(
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
lazyIndex = ChatListLayout.PUBLIC_CHAT_ROW,
|
||||
listMode = listMode,
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
isSelected = publicChatSelected,
|
||||
isHiddenForOverlay = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
|
||||
contextMenuState.isOverlayReplicaActive &&
|
||||
overlayCloneReady,
|
||||
isPressingForContextMenu = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
|
||||
contextMenuState.phase == ChatContextMenuPhase.Pressing,
|
||||
contextMenuPressScaleActive = contextMenuState.phase == ChatContextMenuPhase.Animating &&
|
||||
!contextMenuState.animatingOut,
|
||||
rowRevealProgress = if (contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW) {
|
||||
rowRevealProgress
|
||||
} else {
|
||||
0f
|
||||
},
|
||||
listItemPosition = position,
|
||||
groupItemCount = groupCount,
|
||||
onOpenPublic = onOpenPublic,
|
||||
onAvatarPressStart = { offset, size ->
|
||||
onAvatarContextMenuPressStart(
|
||||
ChatListLayout.PUBLIC_CHAT_ROW,
|
||||
ChatContextMenuTarget.Public,
|
||||
null,
|
||||
offset,
|
||||
size,
|
||||
position,
|
||||
groupCount,
|
||||
)
|
||||
},
|
||||
onAvatarPressEnd = onAvatarContextMenuPressEnd,
|
||||
onAvatarLongPress = { menuPosition, rowOffset, rowSize ->
|
||||
onAvatarContextMenuOpen(
|
||||
ChatListLayout.PUBLIC_CHAT_ROW,
|
||||
ChatContextMenuTarget.Public,
|
||||
null,
|
||||
menuPosition,
|
||||
rowOffset,
|
||||
rowSize,
|
||||
position,
|
||||
groupCount,
|
||||
)
|
||||
},
|
||||
onBodyLongPress = {
|
||||
onEnterSelectionMode(ChatListLayout.PUBLIC_CHAT_ROW, ChatContextMenuTarget.Public, null)
|
||||
},
|
||||
onRowPositioned = { offset, size ->
|
||||
onRowPositioned(ChatListLayout.PUBLIC_CHAT_ROW, offset, size)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (conversations.isNotEmpty()) {
|
||||
item { ChatListItemSpacer() }
|
||||
}
|
||||
}
|
||||
|
||||
conversations.forEachIndexed { index, conversation ->
|
||||
item {
|
||||
val groupIndex = (if (showPublicChat) 1 else 0) + index
|
||||
val lazyIndex = if (showPublicChat) {
|
||||
ChatListLayout.dmRow(index)
|
||||
} else {
|
||||
ChatListLayout.PUBLIC_CHAT_ROW + index
|
||||
}
|
||||
val position = listItemPositionInGroup(groupIndex, groupCount)
|
||||
DmConversationRow(
|
||||
conversation = conversation,
|
||||
lazyIndex = lazyIndex,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
listMode = listMode,
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
isSelected = conversation.otherUserId in selectedOtherUserIds,
|
||||
isHiddenForOverlay = contextMenuState.listIndex == lazyIndex &&
|
||||
contextMenuState.isOverlayReplicaActive &&
|
||||
overlayCloneReady,
|
||||
isPressingForContextMenu = contextMenuState.listIndex == lazyIndex &&
|
||||
contextMenuState.phase == ChatContextMenuPhase.Pressing,
|
||||
contextMenuPressScaleActive = contextMenuState.phase == ChatContextMenuPhase.Animating &&
|
||||
!contextMenuState.animatingOut,
|
||||
rowRevealProgress = if (contextMenuState.listIndex == lazyIndex) {
|
||||
rowRevealProgress
|
||||
} else {
|
||||
0f
|
||||
},
|
||||
listItemPosition = position,
|
||||
groupItemCount = groupCount,
|
||||
onOpenConversation = { onOpenConversation(conversation.otherUserId) },
|
||||
onAvatarPressStart = { offset, size ->
|
||||
onAvatarContextMenuPressStart(
|
||||
lazyIndex,
|
||||
ChatContextMenuTarget.Dm,
|
||||
conversation.otherUserId,
|
||||
offset,
|
||||
size,
|
||||
position,
|
||||
groupCount,
|
||||
)
|
||||
},
|
||||
onAvatarPressEnd = onAvatarContextMenuPressEnd,
|
||||
onAvatarLongPress = { menuPosition, rowOffset, rowSize ->
|
||||
onAvatarContextMenuOpen(
|
||||
lazyIndex,
|
||||
ChatContextMenuTarget.Dm,
|
||||
conversation.otherUserId,
|
||||
menuPosition,
|
||||
rowOffset,
|
||||
rowSize,
|
||||
position,
|
||||
groupCount,
|
||||
)
|
||||
},
|
||||
onBodyLongPress = {
|
||||
onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId)
|
||||
},
|
||||
onRowPositioned = { offset, size -> onRowPositioned(lazyIndex, offset, size) },
|
||||
)
|
||||
}
|
||||
if (index < conversations.lastIndex) {
|
||||
item { ChatListItemSpacer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SearchConversationsList(
|
||||
listState: LazyListState,
|
||||
conversations: List<CachedConversation>,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
modifier: Modifier = Modifier,
|
||||
remoteUsers: List<User> = emptyList(),
|
||||
onOpenConversation: (Int) -> Unit,
|
||||
) {
|
||||
val totalCount = conversations.size + remoteUsers.size
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(bottom = 12.dp),
|
||||
) {
|
||||
if (totalCount > 0) {
|
||||
Category(
|
||||
margin = ChatListCategoryMargin,
|
||||
containerColor = Color.Transparent,
|
||||
roundedCorners = false,
|
||||
) {
|
||||
var resultIndex = 0
|
||||
val dmCount = conversations.size
|
||||
val remoteCount = remoteUsers.size
|
||||
val groupCount = dmCount + remoteCount
|
||||
|
||||
conversations.forEach { conversation ->
|
||||
val lazyIndex = SearchListIndices.resultRow(resultIndex)
|
||||
val position = listItemPositionInGroup(resultIndex, groupCount)
|
||||
resultIndex++
|
||||
item {
|
||||
DmConversationRow(
|
||||
conversation = conversation,
|
||||
lazyIndex = lazyIndex,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
listMode = ChatsListMode.Normal,
|
||||
selectionTransitionProgress = 0f,
|
||||
isSelected = false,
|
||||
isHiddenForOverlay = false,
|
||||
isPressingForContextMenu = false,
|
||||
contextMenuPressScaleActive = false,
|
||||
rowRevealProgress = 0f,
|
||||
listItemPosition = position,
|
||||
groupItemCount = groupCount,
|
||||
onOpenConversation = { onOpenConversation(conversation.otherUserId) },
|
||||
onAvatarPressStart = { _, _ -> },
|
||||
onAvatarPressEnd = {},
|
||||
onAvatarLongPress = { _, _, _ -> },
|
||||
onBodyLongPress = {},
|
||||
onRowPositioned = { _, _ -> },
|
||||
avatarEnabled = true,
|
||||
)
|
||||
}
|
||||
if (resultIndex < groupCount) {
|
||||
item { ChatListItemSpacer() }
|
||||
}
|
||||
}
|
||||
|
||||
remoteUsers.forEach { user ->
|
||||
val position = listItemPositionInGroup(resultIndex, groupCount)
|
||||
resultIndex++
|
||||
item {
|
||||
val cached = ProfileCache.get(user.id)
|
||||
val avatarUrl = cached?.profilePicture ?: user.profile_picture
|
||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: user.displayName?.takeIf { it.isNotBlank() }
|
||||
?: cached?.visibleUsername(ApiClient.user?.id)
|
||||
?: user.username
|
||||
val username = cached?.visibleUsername(ApiClient.user?.id) ?: user.username
|
||||
|
||||
ChatRowScaleContainer(
|
||||
listItemPosition = position,
|
||||
groupItemCount = groupCount,
|
||||
pressScale = 1f,
|
||||
) {
|
||||
ListItem(
|
||||
headline = peerTitle,
|
||||
supportingText = username,
|
||||
containerColor = Color.Transparent,
|
||||
position = position,
|
||||
groupItemCount = groupCount,
|
||||
divider = false,
|
||||
onClick = { onOpenConversation(user.id) },
|
||||
leadingContent = {
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = avatarUrl,
|
||||
displayNameForInitials = peerTitle,
|
||||
enabled = false,
|
||||
onPressStart = {},
|
||||
onPressEnd = {},
|
||||
onLongPress = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (resultIndex < groupCount) {
|
||||
item { ChatListItemSpacer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChatRowScaleContainer(
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
pressScale: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
shadowElevationPx: Float = 0f,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = pressScale
|
||||
scaleY = pressScale
|
||||
transformOrigin = TransformOrigin(0.5f, 0.5f)
|
||||
this.shadowElevation = shadowElevationPx
|
||||
shape = clipShape
|
||||
clip = true
|
||||
}
|
||||
.clip(clipShape)
|
||||
.background(containerColor, clipShape),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SelectionCheckmarkSlot(
|
||||
selectionTransitionProgress: Float,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val selectedCd = stringResource(Res.string.cd_chat_selected)
|
||||
val progress = selectionTransitionProgress.coerceIn(0f, 1f)
|
||||
|
||||
Box(
|
||||
modifier = modifier.width(30.dp * progress),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = selectedCd,
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.offset(x = (-22).dp * (1f - progress))
|
||||
.padding(end = 8.dp)
|
||||
.size(22.dp)
|
||||
.alpha(progress),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ChatRowAvatar(
|
||||
profilePictureUrl: String?,
|
||||
displayNameForInitials: String,
|
||||
enabled: Boolean,
|
||||
onPressStart: () -> Unit,
|
||||
onPressEnd: () -> Unit,
|
||||
onLongPress: (Offset) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.size(40.dp)
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
onPressStart()
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
onPressEnd()
|
||||
}
|
||||
},
|
||||
onLongPress = onLongPress,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Avatar(
|
||||
profilePictureUrl = profilePictureUrl,
|
||||
displayName = displayNameForInitials,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun PublicChatRow(
|
||||
publicChatTitle: String?,
|
||||
publicLastMessagePreview: String?,
|
||||
defaultLastMessage: String,
|
||||
lazyIndex: Int,
|
||||
listMode: ChatsListMode,
|
||||
selectionTransitionProgress: Float,
|
||||
isSelected: Boolean,
|
||||
isHiddenForOverlay: Boolean,
|
||||
isPressingForContextMenu: Boolean,
|
||||
contextMenuPressScaleActive: Boolean,
|
||||
rowRevealProgress: Float,
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
onOpenPublic: () -> Unit,
|
||||
onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit,
|
||||
onAvatarPressEnd: () -> Unit,
|
||||
onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
|
||||
onBodyLongPress: () -> Unit,
|
||||
onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
|
||||
) {
|
||||
var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
var rowSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val pressScaleAnim = remember { Animatable(1f) }
|
||||
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
|
||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
|
||||
when {
|
||||
isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale)
|
||||
isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) ->
|
||||
pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring)
|
||||
else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring)
|
||||
}
|
||||
}
|
||||
|
||||
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
|
||||
|
||||
ChatRowScaleContainer(
|
||||
listItemPosition = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
pressScale = pressScaleAnim.value,
|
||||
modifier = Modifier
|
||||
.alpha(if (isHiddenForOverlay) 0f else 1f)
|
||||
.fillMaxWidth()
|
||||
.clip(clipShape)
|
||||
.combinedClickable(
|
||||
interactionSource = rowInteractionSource,
|
||||
indication = ripple(),
|
||||
onClick = onOpenPublic,
|
||||
onLongClick = if (listMode == ChatsListMode.Normal) {
|
||||
{ onBodyLongPress() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
.onGloballyPositioned { coords ->
|
||||
rowRootOffset = coords.positionInRoot()
|
||||
rowSize = coords.size
|
||||
onRowPositioned(rowRootOffset, rowSize)
|
||||
},
|
||||
) {
|
||||
PublicChatRowContent(
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
listMode = listMode,
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
isSelected = isSelected,
|
||||
listItemPosition = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
avatarEnabled = listMode == ChatsListMode.Normal,
|
||||
onOpenPublic = onOpenPublic,
|
||||
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
|
||||
onAvatarPressEnd = onAvatarPressEnd,
|
||||
onAvatarLongPress = { localOffset ->
|
||||
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
|
||||
},
|
||||
onBodyLongPress = onBodyLongPress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PublicChatRowContent(
|
||||
publicChatTitle: String?,
|
||||
publicLastMessagePreview: String?,
|
||||
defaultLastMessage: String,
|
||||
listMode: ChatsListMode,
|
||||
selectionTransitionProgress: Float,
|
||||
isSelected: Boolean,
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
avatarEnabled: Boolean,
|
||||
onOpenPublic: () -> Unit,
|
||||
onAvatarPressStart: () -> Unit,
|
||||
onAvatarPressEnd: () -> Unit,
|
||||
onAvatarLongPress: (Offset) -> Unit,
|
||||
onBodyLongPress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preview = publicLastMessagePreview ?: defaultLastMessage
|
||||
|
||||
ListItem(
|
||||
headline = publicChatTitle.orEmpty(),
|
||||
supportingText = if (publicChatTitle != null) preview else null,
|
||||
containerColor = Color.Transparent,
|
||||
position = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
divider = false,
|
||||
leadingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SelectionCheckmarkSlot(
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = null,
|
||||
displayNameForInitials = publicChatTitle.orEmpty(),
|
||||
enabled = avatarEnabled,
|
||||
onPressStart = onAvatarPressStart,
|
||||
onPressEnd = onAvatarPressEnd,
|
||||
onLongPress = onAvatarLongPress,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (publicChatTitle == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
bodyModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun DmConversationRow(
|
||||
conversation: CachedConversation,
|
||||
lazyIndex: Int,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
listMode: ChatsListMode,
|
||||
selectionTransitionProgress: Float,
|
||||
isSelected: Boolean,
|
||||
isHiddenForOverlay: Boolean,
|
||||
isPressingForContextMenu: Boolean,
|
||||
contextMenuPressScaleActive: Boolean,
|
||||
rowRevealProgress: Float,
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
onOpenConversation: () -> Unit,
|
||||
onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit,
|
||||
onAvatarPressEnd: () -> Unit,
|
||||
onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
|
||||
onBodyLongPress: () -> Unit,
|
||||
onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
|
||||
avatarEnabled: Boolean = listMode == ChatsListMode.Normal,
|
||||
) {
|
||||
var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
var rowSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val pressScaleAnim = remember { Animatable(1f) }
|
||||
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
|
||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
|
||||
when {
|
||||
isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale)
|
||||
isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) ->
|
||||
pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring)
|
||||
else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring)
|
||||
}
|
||||
}
|
||||
|
||||
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
|
||||
|
||||
ChatRowScaleContainer(
|
||||
listItemPosition = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
pressScale = pressScaleAnim.value,
|
||||
modifier = Modifier
|
||||
.alpha(if (isHiddenForOverlay) 0f else 1f)
|
||||
.fillMaxWidth()
|
||||
.clip(clipShape)
|
||||
.combinedClickable(
|
||||
interactionSource = rowInteractionSource,
|
||||
indication = ripple(),
|
||||
onClick = onOpenConversation,
|
||||
onLongClick = if (listMode == ChatsListMode.Normal) {
|
||||
{ onBodyLongPress() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
.onGloballyPositioned { coords ->
|
||||
rowRootOffset = coords.positionInRoot()
|
||||
rowSize = coords.size
|
||||
onRowPositioned(rowRootOffset, rowSize)
|
||||
},
|
||||
) {
|
||||
DmConversationRowContent(
|
||||
conversation = conversation,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
listMode = listMode,
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
isSelected = isSelected,
|
||||
listItemPosition = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
avatarEnabled = avatarEnabled,
|
||||
onOpenConversation = onOpenConversation,
|
||||
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
|
||||
onAvatarPressEnd = onAvatarPressEnd,
|
||||
onAvatarLongPress = { localOffset ->
|
||||
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
|
||||
},
|
||||
onBodyLongPress = onBodyLongPress,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DmConversationRowContent(
|
||||
conversation: CachedConversation,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
listMode: ChatsListMode,
|
||||
selectionTransitionProgress: Float,
|
||||
isSelected: Boolean,
|
||||
listItemPosition: ListItemPosition,
|
||||
groupItemCount: Int,
|
||||
avatarEnabled: Boolean,
|
||||
onOpenConversation: () -> Unit,
|
||||
onAvatarPressStart: () -> Unit,
|
||||
onAvatarPressEnd: () -> Unit,
|
||||
onAvatarLongPress: (Offset) -> Unit,
|
||||
onBodyLongPress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cached = ProfileCache.get(conversation.otherUserId)
|
||||
val avatarUrl = cached?.profilePicture
|
||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: cached?.visibleUsername(ApiClient.user?.id)
|
||||
?: conversation.displayName.ifBlank {
|
||||
stringResource(Res.string.user_fallback, conversation.otherUserId)
|
||||
}
|
||||
val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage }
|
||||
val status = statusMap[conversation.otherUserId]
|
||||
val typingUsers = status?.typingUsernames.orEmpty()
|
||||
val isTyping = typingUsers.isNotEmpty()
|
||||
val isOnline = status?.online ?: (cached?.online == true)
|
||||
val statusKey = when {
|
||||
isTyping -> "typing:${typingUsers.joinToString("|")}"
|
||||
isOnline -> "online"
|
||||
else -> "offline"
|
||||
}
|
||||
ListItem(
|
||||
headline = peerTitle,
|
||||
supportingSlot = {
|
||||
AnimatedContent(
|
||||
targetState = statusKey,
|
||||
transitionSpec = {
|
||||
(slideInVertically { it / 2 } + fadeIn()) togetherWith
|
||||
(slideOutVertically { -it / 2 } + fadeOut())
|
||||
},
|
||||
label = "dm_status_${conversation.otherUserId}",
|
||||
) { state ->
|
||||
when {
|
||||
state.startsWith("typing:") -> TypingIndicator(typingUsers = typingUsers)
|
||||
state == "online" -> Text(
|
||||
text = stringResource(Res.string.presence_online),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> Text(
|
||||
text = preview,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = Color.Transparent,
|
||||
position = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
divider = false,
|
||||
leadingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SelectionCheckmarkSlot(
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
isSelected = isSelected,
|
||||
)
|
||||
ChatRowAvatar(
|
||||
profilePictureUrl = avatarUrl,
|
||||
displayNameForInitials = peerTitle,
|
||||
enabled = avatarEnabled,
|
||||
onPressStart = onAvatarPressStart,
|
||||
onPressEnd = onAvatarPressEnd,
|
||||
onLongPress = onAvatarLongPress,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (conversation.unreadCount > 0 && listMode == ChatsListMode.Normal) {
|
||||
Text(stringResource(Res.string.unread_count, conversation.unreadCount))
|
||||
}
|
||||
},
|
||||
bodyModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Forward reveal only: 0.96 at progress 0 → 1.0 at progress 1. Reverse uses [applyPressScale] = false → 1.0. */
|
||||
internal fun chatRowContextMenuScale(
|
||||
revealProgress: Float,
|
||||
applyPressScale: Boolean = true,
|
||||
): Float =
|
||||
if (!applyPressScale) {
|
||||
1f
|
||||
} else {
|
||||
ChatRowContextMenuPressScale + (1f - ChatRowContextMenuPressScale) * revealProgress
|
||||
}
|
||||
|
||||
internal fun chatContextMenuCenteredBlockTopY(
|
||||
rowOffsetY: Float,
|
||||
blockHeightPx: Float,
|
||||
overlayOriginY: Float,
|
||||
overlayHeightPx: Int,
|
||||
paddingPx: Float,
|
||||
): Float {
|
||||
if (blockHeightPx <= 0f || overlayHeightPx == 0) return rowOffsetY
|
||||
return overlayOriginY + ((overlayHeightPx - blockHeightPx) / 2f).coerceAtLeast(paddingPx)
|
||||
}
|
||||
|
||||
internal fun chatContextMenuClampedMenuX(
|
||||
preferredLeftX: Float,
|
||||
menuWidth: Int,
|
||||
overlayOriginX: Float,
|
||||
overlayWidth: Int,
|
||||
paddingPx: Float,
|
||||
): Int {
|
||||
if (menuWidth <= 0 || overlayWidth == 0) return preferredLeftX.toInt()
|
||||
var x = preferredLeftX.toInt()
|
||||
val rightEdge = (overlayOriginX + overlayWidth - paddingPx).toInt()
|
||||
val leftEdge = (overlayOriginX + paddingPx).toInt()
|
||||
if (x < leftEdge) x = leftEdge
|
||||
if (x + menuWidth > rightEdge) x = rightEdge - menuWidth
|
||||
return x
|
||||
}
|
||||
|
||||
internal const val ChatRowContextMenuPressScale = 0.96f
|
||||
internal val ChatRowPressSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
)
|
||||
internal val ChatContextMenuRevealSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
)
|
||||
internal val ChatContextMenuOpenSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
)
|
||||
internal val ChatSelectionTransitionSpring: SpringSpec<Float> = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
)
|
||||
@@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -52,6 +51,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.resetFocus
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -59,15 +59,17 @@ import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.CachedConversation
|
||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||
import ru.fromchat.api.local.db.store.MessageRepository
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||
import ru.fromchat.api.local.db.store.visibleUsername
|
||||
import ru.fromchat.api.schema.user.User
|
||||
import ru.fromchat.chat_last_mesaage
|
||||
import ru.fromchat.chat_preview_attachment
|
||||
import ru.fromchat.search_hint
|
||||
import ru.fromchat.search_not_found
|
||||
import ru.fromchat.search_not_found_message
|
||||
import ru.fromchat.search_title
|
||||
import ru.fromchat.ui.components.BackHandler
|
||||
import ru.fromchat.ui.components.SearchBar
|
||||
import ru.fromchat.ui.components.SearchBarSharedElement
|
||||
import ru.fromchat.ui.components.Text
|
||||
@@ -85,9 +87,11 @@ fun ChatsSearchScreen(
|
||||
val searchListState = rememberLazyListState()
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
||||
val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment)
|
||||
val searchHint = stringResource(Res.string.search_hint)
|
||||
val searchBarHint = stringResource(Res.string.search_title)
|
||||
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
|
||||
var remoteUsers by remember { mutableStateOf<List<User>>(emptyList()) }
|
||||
val statusSubscriptionScope = rememberCoroutineScope()
|
||||
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
@@ -99,7 +103,7 @@ fun ChatsSearchScreen(
|
||||
val searchQuery = searchText.trim().lowercase().trimStart('@')
|
||||
val filteredDmConversations = remember(dmConversations, searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
dmConversations
|
||||
emptyList()
|
||||
} else {
|
||||
dmConversations.filter { conv ->
|
||||
matchesSearchConversations(conv, searchQuery)
|
||||
@@ -107,19 +111,50 @@ fun ChatsSearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val searchUiState by remember(searchText, filteredDmConversations) {
|
||||
derivedStateOf {
|
||||
when {
|
||||
searchText.isBlank() -> SearchScreenState.EmptyQuery
|
||||
filteredDmConversations.isEmpty() -> SearchScreenState.NotFound
|
||||
else -> SearchScreenState.Results
|
||||
val searchResults = remember(filteredDmConversations, remoteUsers, searchQuery) {
|
||||
if (searchQuery.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
val dmUserIds = filteredDmConversations.map { it.otherUserId }.toSet()
|
||||
val remoteOnly = remoteUsers.filter { it.id !in dmUserIds }
|
||||
filteredDmConversations.map { SearchResult.Conversation(it) } +
|
||||
remoteOnly.map { SearchResult.UserResult(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val searchUiState = when {
|
||||
searchText.isBlank() -> SearchScreenState.EmptyQuery
|
||||
searchResults.isEmpty() -> SearchScreenState.NotFound
|
||||
else -> SearchScreenState.Results
|
||||
}
|
||||
LaunchedEffect(searchQuery) {
|
||||
if (searchQuery.length < 2) {
|
||||
remoteUsers = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(300)
|
||||
val querySnapshot = searchQuery
|
||||
runCatching {
|
||||
ApiClient.searchUsers(querySnapshot)
|
||||
}.onSuccess { users ->
|
||||
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
|
||||
users.forEach { ProfileCache.mergeFromDmUser(it) }
|
||||
remoteUsers = users
|
||||
}
|
||||
}.onFailure {
|
||||
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
|
||||
remoteUsers = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Load cached DM conversations first for immediate display.
|
||||
runCatching {
|
||||
dmConversations = MessageCacheStore.loadCachedDmConversations()
|
||||
MessageCacheStore.loadCachedDmConversations()
|
||||
}.onSuccess { conversations ->
|
||||
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
|
||||
dmConversations = conversations
|
||||
}
|
||||
|
||||
// Then refresh from network and update cache + state.
|
||||
@@ -128,17 +163,18 @@ fun ChatsSearchScreen(
|
||||
}.onSuccess { conversations ->
|
||||
runCatching {
|
||||
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||
MessageCacheStore.replaceDmConversations(conversations)
|
||||
dmConversations = MessageCacheStore.loadCachedDmConversations()
|
||||
MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview)
|
||||
dmConversations = MessageRepository.loadCachedDmConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filteredDmConversations, searchListState) {
|
||||
LaunchedEffect(searchResults, searchListState) {
|
||||
snapshotFlow {
|
||||
searchListState.layoutInfo.visibleItemsInfo
|
||||
.mapNotNull { item ->
|
||||
filteredDmConversations.getOrNull(item.index)?.otherUserId
|
||||
SearchListIndices.resultIndexFromLazy(item.index)
|
||||
?.let { searchResults.getOrNull(it)?.userId }
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.toSet()
|
||||
@@ -160,6 +196,7 @@ fun ChatsSearchScreen(
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
hideIme()
|
||||
if (subscribedDmUserIds.isNotEmpty()) {
|
||||
statusSubscriptionScope.launch {
|
||||
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
|
||||
@@ -169,11 +206,6 @@ fun ChatsSearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
hideIme()
|
||||
onBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
@@ -244,16 +276,12 @@ fun ChatsSearchScreen(
|
||||
SearchConversationsList(
|
||||
listState = searchListState,
|
||||
conversations = filteredDmConversations,
|
||||
remoteUsers = remoteUsers.filter { user ->
|
||||
filteredDmConversations.none { it.otherUserId == user.id }
|
||||
},
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
onOpenProfile = { userId ->
|
||||
if (userId != 0) {
|
||||
onOpenProfile(userId)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onOpenConversation = { userId ->
|
||||
if (userId != 0) {
|
||||
onOpenConversation(userId)
|
||||
@@ -267,16 +295,26 @@ fun ChatsSearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String): Boolean {
|
||||
val cached = ProfileCache.get(conv.otherUserId)
|
||||
val candidates = buildList {
|
||||
add(conv.displayName)
|
||||
cached?.displayName?.let { add(it) }
|
||||
cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
|
||||
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) =
|
||||
ProfileCache.get(conv.otherUserId).let { cached ->
|
||||
buildList {
|
||||
add(conv.displayName)
|
||||
cached?.displayName?.let { add(it) }
|
||||
cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
|
||||
}.any { candidate ->
|
||||
candidate.lowercase().contains(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.any { candidate ->
|
||||
candidate.lowercase().contains(normalizedQuery)
|
||||
private sealed interface SearchResult {
|
||||
val userId: Int
|
||||
|
||||
data class Conversation(val conversation: CachedConversation) : SearchResult {
|
||||
override val userId: Int = conversation.otherUserId
|
||||
}
|
||||
|
||||
data class UserResult(val user: User) : SearchResult {
|
||||
override val userId: Int = user.id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package ru.fromchat.ui.main.chats
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.pr0gramm3r101.components.ListItemPosition
|
||||
import ru.fromchat.api.local.db.store.CachedConversation
|
||||
import ru.fromchat.api.local.db.store.UserStatus
|
||||
|
||||
enum class ChatsListMode {
|
||||
Normal,
|
||||
Selecting,
|
||||
}
|
||||
|
||||
enum class ChatListFilter {
|
||||
Active,
|
||||
}
|
||||
|
||||
enum class ChatContextMenuTarget {
|
||||
Dm,
|
||||
Public,
|
||||
}
|
||||
|
||||
enum class ChatContextMenuPhase {
|
||||
Closed,
|
||||
/** Avatar held — scale-down only, no blur. */
|
||||
Pressing,
|
||||
/** Blur + scale-up + row centering run concurrently. */
|
||||
Animating,
|
||||
/** Animations finished — context menu visible. */
|
||||
Open,
|
||||
}
|
||||
|
||||
data class ChatContextMenuState(
|
||||
val phase: ChatContextMenuPhase = ChatContextMenuPhase.Closed,
|
||||
val target: ChatContextMenuTarget = ChatContextMenuTarget.Dm,
|
||||
val otherUserId: Int? = null,
|
||||
val listIndex: Int = -1,
|
||||
val positionX: Int = 0,
|
||||
val positionY: Int = 0,
|
||||
val rowOffset: Offset = Offset.Zero,
|
||||
val rowSize: IntSize = IntSize.Zero,
|
||||
val animatingOut: Boolean = false,
|
||||
val listItemPosition: ListItemPosition = ListItemPosition.MIDDLE,
|
||||
val groupItemCount: Int = 1,
|
||||
) {
|
||||
val isOpen: Boolean get() = phase == ChatContextMenuPhase.Open
|
||||
val isPressing: Boolean get() = phase == ChatContextMenuPhase.Pressing
|
||||
val isOverlayActive: Boolean get() = phase != ChatContextMenuPhase.Closed
|
||||
/** List row is hidden only while the MainScreen overlay replica is shown. */
|
||||
val isOverlayReplicaActive: Boolean get() =
|
||||
phase == ChatContextMenuPhase.Animating || phase == ChatContextMenuPhase.Open
|
||||
val isBlurActive: Boolean get() = phase == ChatContextMenuPhase.Animating || phase == ChatContextMenuPhase.Open
|
||||
}
|
||||
|
||||
data class ChatsSelection(
|
||||
val publicChatSelected: Boolean = false,
|
||||
val selectedOtherUserIds: Set<Int> = emptySet(),
|
||||
) {
|
||||
val count: Int get() = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0
|
||||
val isEmpty: Boolean get() = !publicChatSelected && selectedOtherUserIds.isEmpty()
|
||||
}
|
||||
|
||||
data class ChatsBulkActions(
|
||||
val canDelete: Boolean = false,
|
||||
val canMarkRead: Boolean = false,
|
||||
)
|
||||
|
||||
fun resolveChatsBulkActions(selection: ChatsSelection): ChatsBulkActions =
|
||||
if (selection.isEmpty) {
|
||||
ChatsBulkActions()
|
||||
} else {
|
||||
val onlyDms = !selection.publicChatSelected && selection.selectedOtherUserIds.isNotEmpty()
|
||||
ChatsBulkActions(canDelete = onlyDms, canMarkRead = true)
|
||||
}
|
||||
|
||||
/** Snapshot of chats context-menu overlay data rendered above [hazeSource] in [ru.fromchat.ui.main.MainScreen]. */
|
||||
data class ChatContextMenuOverlayUiState(
|
||||
val contextMenuState: ChatContextMenuState,
|
||||
val blurProgress: Float = 0f,
|
||||
val listFilter: ChatListFilter = ChatListFilter.Active,
|
||||
val publicChatTitle: String? = null,
|
||||
val publicLastMessagePreview: String? = null,
|
||||
val publicChatLink: String? = null,
|
||||
val defaultLastMessage: String = "",
|
||||
val conversations: List<CachedConversation> = emptyList(),
|
||||
val statusMap: Map<Int, UserStatus> = emptyMap(),
|
||||
val listMode: ChatsListMode = ChatsListMode.Normal,
|
||||
val selectionTransitionProgress: Float = 0f,
|
||||
val publicChatSelected: Boolean = false,
|
||||
val selectedOtherUserIds: Set<Int> = emptySet(),
|
||||
val isReadOnly: Boolean = false,
|
||||
val callsEnabled: Boolean = false,
|
||||
val publicHasUnread: Boolean = false,
|
||||
)
|
||||
|
||||
class ChatContextMenuOverlayController {
|
||||
var uiState by mutableStateOf<ChatContextMenuOverlayUiState?>(null)
|
||||
var blurProgress by mutableFloatStateOf(0f)
|
||||
/** Shared row scale progress (0 = pressed, 1 = full) for overlay ↔ list handoff. */
|
||||
var rowRevealProgress by mutableFloatStateOf(0f)
|
||||
/** True once the overlay row clone has been composed and positioned. */
|
||||
var overlayCloneReady by mutableStateOf(false)
|
||||
var onStateChange: (ChatContextMenuState) -> Unit = {}
|
||||
var onDismiss: () -> Unit = {}
|
||||
var onMessage: () -> Unit = {}
|
||||
var onCall: (Int) -> Unit = {}
|
||||
var onLink: () -> Unit = {}
|
||||
var onMarkRead: (Int) -> Unit = {}
|
||||
var onMarkPublicRead: () -> Unit = {}
|
||||
var onDelete: (Int) -> Unit = {}
|
||||
var onSelect: () -> Unit = {}
|
||||
var onOverlayCloneReady: () -> Unit = {}
|
||||
|
||||
fun clear() {
|
||||
uiState = null
|
||||
blurProgress = 0f
|
||||
rowRevealProgress = 0f
|
||||
overlayCloneReady = false
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
package ru.fromchat.ui.main.chats
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import ru.fromchat.ui.components.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class ChatsTabBannerCandidate(
|
||||
val id: String,
|
||||
val priority: Int,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val icon: String = "ⓘ",
|
||||
val onTap: () -> Unit = {},
|
||||
val onDismiss: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChatsTabBannerHost(
|
||||
candidates: List<ChatsTabBannerCandidate>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dismissedByUser = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
val sortedVisibleCandidates = remember(candidates, dismissedByUser.size) {
|
||||
candidates
|
||||
.filter { candidate ->
|
||||
val canDismiss = candidate.onDismiss != null
|
||||
!canDismiss || dismissedByUser[candidate.id] != true
|
||||
}
|
||||
.sortedByDescending { it.priority }
|
||||
}
|
||||
val activeCandidate = sortedVisibleCandidates.firstOrNull()
|
||||
|
||||
AnimatedVisibility(activeCandidate != null) {
|
||||
activeCandidate?.let { candidate ->
|
||||
ChatsTabBanner(
|
||||
candidate = candidate,
|
||||
modifier = modifier.padding(
|
||||
horizontal = 12.dp,
|
||||
vertical = 8.dp
|
||||
),
|
||||
onDismiss = {
|
||||
if (candidate.onDismiss != null) {
|
||||
dismissedByUser[candidate.id] = true
|
||||
candidate.onDismiss.invoke()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatsTabBanner(
|
||||
candidate: ChatsTabBannerCandidate,
|
||||
modifier: Modifier = Modifier,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val canDismiss = candidate.onDismiss != null
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = candidate.onTap)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = candidate.icon,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = candidate.title,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
Text(
|
||||
text = candidate.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3
|
||||
)
|
||||
}
|
||||
if (canDismiss) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Dismiss banner"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -38,11 +40,14 @@ import org.jetbrains.compose.resources.vectorResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.about
|
||||
import ru.fromchat.about_link_max
|
||||
import ru.fromchat.about_link_privacy
|
||||
import ru.fromchat.about_link_telegram
|
||||
import ru.fromchat.about_link_terms
|
||||
import ru.fromchat.about_link_website
|
||||
import ru.fromchat.about_version
|
||||
import ru.fromchat.app_desc
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.legal.DocumentType
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.BrandTitle
|
||||
|
||||
@@ -146,6 +151,7 @@ fun AboutScreen() {
|
||||
headline = stringResource(Res.string.about_link_website),
|
||||
supportingText = URL_WEBSITE,
|
||||
onClick = { uriHandler.openUri(URL_WEBSITE) },
|
||||
divider = true,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Website,
|
||||
@@ -155,6 +161,33 @@ fun AboutScreen() {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.about_link_privacy),
|
||||
onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) },
|
||||
divider = true,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Shield,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.about_link_terms),
|
||||
onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
package ru.fromchat.ui.profile
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.action_save
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.schema.core.ErrorResponse
|
||||
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||
import ru.fromchat.auth_char_count
|
||||
import ru.fromchat.auth_username_taken
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.display_name
|
||||
import ru.fromchat.display_name_error
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.profile_edit_saved
|
||||
import ru.fromchat.profile_edit_title
|
||||
import ru.fromchat.profile_headline_bio
|
||||
import ru.fromchat.server_config_action_reset
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.components.DisabledBringIntoViewSpec
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.HazeActionButton
|
||||
import ru.fromchat.ui.components.LazyListFocusScrollEffect
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.expressiveStepFieldColors
|
||||
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
|
||||
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
|
||||
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||
import ru.fromchat.ui.components.scrollFocusedItemIntoView
|
||||
import ru.fromchat.ui.components.trackLazyListFocus
|
||||
import ru.fromchat.profile_bio_length_error
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import ru.fromchat.username
|
||||
import ru.fromchat.username_length_error
|
||||
|
||||
private const val DISPLAY_NAME_MAX = 64
|
||||
private const val BIO_MAX = 500
|
||||
private const val USERNAME_MIN = 3
|
||||
private const val USERNAME_MAX = 20
|
||||
|
||||
private object EditProfileLazyListIndices {
|
||||
const val USERNAME_FIELD = 1
|
||||
const val DISPLAY_NAME_FIELD = 2
|
||||
const val BIO_FIELD = 3
|
||||
}
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalMaterial3ExpressiveApi::class,
|
||||
ExperimentalFoundationApi::class,
|
||||
ExperimentalHazeMaterialsApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun EditProfileScreen(
|
||||
onBack: () -> Unit,
|
||||
initialFocusField: EditProfileFocusField? = null,
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val fieldColors = expressiveStepFieldColors()
|
||||
val hazeState = rememberHazeState()
|
||||
val focusScrollState = rememberLazyListFocusScrollState()
|
||||
val listState = rememberLazyListState()
|
||||
val usernameFocusRequester = remember { FocusRequester() }
|
||||
val displayNameFocusRequester = remember { FocusRequester() }
|
||||
val bioFocusRequester = remember { FocusRequester() }
|
||||
|
||||
var usernameField by remember { mutableStateOf(TextFieldValue()) }
|
||||
var displayNameField by remember { mutableStateOf(TextFieldValue()) }
|
||||
var bioField by remember { mutableStateOf(TextFieldValue()) }
|
||||
var savedUsername by remember { mutableStateOf("") }
|
||||
var savedDisplayName by remember { mutableStateOf("") }
|
||||
var savedBio by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var loaded by remember { mutableStateOf(false) }
|
||||
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
val savedMessage = stringResource(Res.string.profile_edit_saved)
|
||||
val unexpectedError = stringResource(Res.string.error_unexpected)
|
||||
val displayNameError = stringResource(Res.string.display_name_error)
|
||||
val usernameLengthError = stringResource(Res.string.username_length_error)
|
||||
val usernameTaken = stringResource(Res.string.auth_username_taken)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val profile = withContext(Dispatchers.Default) {
|
||||
runCatching { ApiClient.getOwnProfile() }.getOrNull()
|
||||
?: ApiClient.user?.id?.let { ProfileCache.get(it) }
|
||||
}
|
||||
profile?.let {
|
||||
usernameField = TextFieldValue(it.username)
|
||||
displayNameField = TextFieldValue(it.displayName.orEmpty())
|
||||
bioField = TextFieldValue(it.bio.orEmpty())
|
||||
savedUsername = it.username
|
||||
savedDisplayName = it.displayName.orEmpty()
|
||||
savedBio = it.bio.orEmpty()
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
LaunchedEffect(loaded, initialFocusField) {
|
||||
if (!loaded || initialFocusField == null) return@LaunchedEffect
|
||||
val itemIndex = when (initialFocusField) {
|
||||
EditProfileFocusField.Username -> EditProfileLazyListIndices.USERNAME_FIELD
|
||||
EditProfileFocusField.DisplayName -> EditProfileLazyListIndices.DISPLAY_NAME_FIELD
|
||||
EditProfileFocusField.Bio -> EditProfileLazyListIndices.BIO_FIELD
|
||||
}
|
||||
val focusRequester = when (initialFocusField) {
|
||||
EditProfileFocusField.Username -> usernameFocusRequester
|
||||
EditProfileFocusField.DisplayName -> displayNameFocusRequester
|
||||
EditProfileFocusField.Bio -> bioFocusRequester
|
||||
}
|
||||
delay(100)
|
||||
listState.scrollFocusedItemIntoView(
|
||||
itemIndex,
|
||||
viewportMarginPx = with(density) { 12.dp.toPx() },
|
||||
)
|
||||
when (initialFocusField) {
|
||||
EditProfileFocusField.Username -> {
|
||||
usernameField = usernameField.copy(
|
||||
selection = TextRange(0, usernameField.text.length),
|
||||
)
|
||||
}
|
||||
EditProfileFocusField.DisplayName -> {
|
||||
displayNameField = displayNameField.copy(
|
||||
selection = TextRange(0, displayNameField.text.length),
|
||||
)
|
||||
}
|
||||
EditProfileFocusField.Bio -> Unit
|
||||
}
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
val username = usernameField.text
|
||||
val displayName = displayNameField.text
|
||||
val bio = bioField.text
|
||||
val trimmedUsername = username.trim()
|
||||
val trimmedDisplayName = displayName.trim()
|
||||
val trimmedBio = bio.trim()
|
||||
|
||||
val usernameError = trimmedUsername.isNotEmpty() &&
|
||||
trimmedUsername.length !in USERNAME_MIN..USERNAME_MAX
|
||||
val displayNameFieldError = trimmedDisplayName.isNotEmpty() &&
|
||||
(trimmedDisplayName.isBlank() || trimmedDisplayName.length > DISPLAY_NAME_MAX)
|
||||
val bioError = bio.length > BIO_MAX
|
||||
|
||||
val hasChanges = loaded && (
|
||||
trimmedUsername != savedUsername.trim() ||
|
||||
trimmedDisplayName != savedDisplayName.trim() ||
|
||||
trimmedBio != savedBio.trim()
|
||||
)
|
||||
|
||||
val hasValidationErrors = usernameError || displayNameFieldError || bioError ||
|
||||
(hasChanges && trimmedDisplayName.isBlank())
|
||||
|
||||
val canSave = loaded && hasChanges && !hasValidationErrors && !busy
|
||||
|
||||
val avatarDisplayName = trimmedDisplayName.ifBlank { trimmedUsername }.ifBlank { "?" }
|
||||
|
||||
fun showSnack(text: String) {
|
||||
scope.launch {
|
||||
snackbarHostState.showReplacingSnackbar(
|
||||
message = text,
|
||||
withDismissAction = false,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetToSaved() {
|
||||
usernameField = TextFieldValue(savedUsername)
|
||||
displayNameField = TextFieldValue(savedDisplayName)
|
||||
bioField = TextFieldValue(savedBio)
|
||||
}
|
||||
|
||||
fun saveProfile() {
|
||||
if (!canSave) return
|
||||
|
||||
if (trimmedUsername.length !in USERNAME_MIN..USERNAME_MAX) {
|
||||
showSnack(usernameLengthError)
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedDisplayName.isBlank() || trimmedDisplayName.length > DISPLAY_NAME_MAX) {
|
||||
showSnack(displayNameError)
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedBio.length > BIO_MAX) {
|
||||
showSnack(unexpectedError)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
if (
|
||||
!trimmedUsername.equals(savedUsername, ignoreCase = true) &&
|
||||
runCatching { ApiClient.checkUsername(trimmedUsername).exists }
|
||||
.getOrDefault(false)
|
||||
) {
|
||||
showSnack(usernameTaken)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val response = withContext(Dispatchers.Default) {
|
||||
ApiClient.updateProfile(
|
||||
username = trimmedUsername,
|
||||
displayName = trimmedDisplayName,
|
||||
bio = trimmedBio.ifEmpty { "" },
|
||||
)
|
||||
}
|
||||
|
||||
savedUsername = response.username
|
||||
savedDisplayName = response.displayName.orEmpty()
|
||||
savedBio = response.bio.orEmpty()
|
||||
usernameField = TextFieldValue(savedUsername)
|
||||
displayNameField = TextFieldValue(savedDisplayName)
|
||||
bioField = TextFieldValue(savedBio)
|
||||
|
||||
val ownUserId = ApiClient.user?.id
|
||||
if (ownUserId != null) {
|
||||
val existing = ProfileCache.get(ownUserId)
|
||||
val updatedProfile = existing?.copy(
|
||||
username = response.username,
|
||||
displayName = response.displayName,
|
||||
bio = response.bio,
|
||||
) ?: UserProfile(
|
||||
id = ownUserId,
|
||||
username = response.username,
|
||||
displayName = response.displayName,
|
||||
bio = response.bio,
|
||||
profilePicture = ApiClient.user?.profile_picture,
|
||||
online = ApiClient.user?.online ?: false,
|
||||
lastSeen = ApiClient.user?.last_seen,
|
||||
createdAt = existing?.createdAt,
|
||||
)
|
||||
ProfileCache.put(updatedProfile)
|
||||
}
|
||||
|
||||
showSnack(savedMessage)
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set(ProfileRoutes.REFRESH_KEY, true)
|
||||
onBack()
|
||||
} catch (error: ClientRequestException) {
|
||||
val detail = if (error.response.status.value in arrayOf(400, 401, 403, 429)) {
|
||||
runCatching { error.response.body<ErrorResponse>().detail }.getOrDefault(unexpectedError)
|
||||
} else {
|
||||
unexpectedError
|
||||
}
|
||||
showSnack(detail)
|
||||
} catch (_: Exception) {
|
||||
showSnack(unexpectedError)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||
bottomBar = {
|
||||
HazeActionButton(
|
||||
hazeState = hazeState,
|
||||
onClick = ::saveProfile,
|
||||
enabled = canSave,
|
||||
loading = busy,
|
||||
) {
|
||||
Text(stringResource(Res.string.action_save))
|
||||
}
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.profile_edit_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = ::resetToSaved,
|
||||
enabled = loaded && hasChanges && !busy,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RestartAlt,
|
||||
contentDescription = stringResource(Res.string.server_config_action_reset),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
|
||||
progressive = HazeProgressive.verticalGradient(
|
||||
startIntensity = 1f,
|
||||
endIntensity = 0f,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyListFocusScrollEffect(
|
||||
listState = listState,
|
||||
focusState = focusScrollState,
|
||||
viewportBoundsInWindow = listViewportBounds,
|
||||
contentPaddingTop = innerPadding.calculateTopPadding(),
|
||||
contentPaddingBottom = innerPadding.calculateBottomPadding(),
|
||||
)
|
||||
|
||||
DisabledBringIntoViewSpec {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.hazeSource(hazeState)
|
||||
.onGloballyPositioned { listViewportBounds = it.boundsInWindow() },
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = innerPadding,
|
||||
) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Avatar(
|
||||
profilePictureUrl = ApiClient.user?.profile_picture,
|
||||
displayName = avatarDisplayName,
|
||||
modifier = Modifier.size(96.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = usernameField,
|
||||
onValueChange = { usernameField = it },
|
||||
label = { Text(stringResource(Res.string.username)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(usernameFocusRequester)
|
||||
.trackLazyListFocus(focusScrollState, EditProfileLazyListIndices.USERNAME_FIELD)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
enabled = loaded && !busy,
|
||||
singleLine = true,
|
||||
isError = usernameError,
|
||||
supportingText = if (usernameError) {
|
||||
{ Text(stringResource(Res.string.username_length_error)) }
|
||||
} else null,
|
||||
colors = fieldColors,
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = displayNameField,
|
||||
onValueChange = { displayNameField = it },
|
||||
label = { Text(stringResource(Res.string.display_name)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(displayNameFocusRequester)
|
||||
.trackLazyListFocus(focusScrollState, EditProfileLazyListIndices.DISPLAY_NAME_FIELD)
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
enabled = loaded && !busy,
|
||||
singleLine = true,
|
||||
isError = displayNameFieldError || (hasChanges && trimmedDisplayName.isBlank()),
|
||||
supportingText = {
|
||||
when {
|
||||
displayNameFieldError || (hasChanges && trimmedDisplayName.isBlank()) ->
|
||||
Text(stringResource(Res.string.display_name_error))
|
||||
else ->
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.auth_char_count,
|
||||
displayName.length,
|
||||
DISPLAY_NAME_MAX,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = fieldColors,
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = bioField,
|
||||
onValueChange = { bioField = it },
|
||||
label = { Text(stringResource(Res.string.profile_headline_bio)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(bioFocusRequester)
|
||||
.trackLazyListFocus(focusScrollState, EditProfileLazyListIndices.BIO_FIELD),
|
||||
enabled = loaded && !busy,
|
||||
minLines = 3,
|
||||
maxLines = 6,
|
||||
isError = bioError,
|
||||
supportingText = {
|
||||
if (bioError) {
|
||||
Text(stringResource(Res.string.profile_bio_length_error, BIO_MAX))
|
||||
} else if (bio.isNotEmpty()) {
|
||||
Text(stringResource(Res.string.auth_char_count, bio.length, BIO_MAX))
|
||||
}
|
||||
},
|
||||
colors = fieldColors,
|
||||
shape = SettingsPasswordOutlineFieldShape,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ru.fromchat.ui.profile
|
||||
|
||||
enum class EditProfileFocusField(val arg: String) {
|
||||
Username("username"),
|
||||
DisplayName("display_name"),
|
||||
Bio("bio"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromArg(value: String?) =
|
||||
value?.takeIf { it.isNotBlank() }?.let { arg -> entries.firstOrNull { it.arg == arg } }
|
||||
}
|
||||
}
|
||||
|
||||
object ProfileRoutes {
|
||||
const val ARG_FOCUS = "focus"
|
||||
const val Edit = "profile/edit?focus={focus}"
|
||||
const val REFRESH_KEY = "profile_refresh"
|
||||
|
||||
fun editRoute(focus: EditProfileFocusField? = null): String =
|
||||
if (focus == null) "profile/edit"
|
||||
else "profile/edit?focus=${focus.arg}"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
package ru.fromchat.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.month_name_apr
|
||||
import ru.fromchat.month_name_aug
|
||||
import ru.fromchat.month_name_dec
|
||||
import ru.fromchat.month_name_feb
|
||||
import ru.fromchat.month_name_jan
|
||||
import ru.fromchat.month_name_jul
|
||||
import ru.fromchat.month_name_jun
|
||||
import ru.fromchat.month_name_mar
|
||||
import ru.fromchat.month_name_may
|
||||
import ru.fromchat.month_name_nov
|
||||
import ru.fromchat.month_name_oct
|
||||
import ru.fromchat.month_name_sep
|
||||
import ru.fromchat.profile_registration_date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
private fun formatFromXmlTemplate(template: String, vararg args: Any): String {
|
||||
var result = template
|
||||
args.forEachIndexed { index, arg ->
|
||||
val n = index + 1
|
||||
val text = arg.toString()
|
||||
result = result.replace($$"%$${n}$s", text).replace($$"%$${n}$d", text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
data class RegistrationDateFormatStrings(
|
||||
val template: String,
|
||||
val monthName: (Int) -> String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberRegistrationDateFormatStrings(): RegistrationDateFormatStrings {
|
||||
val template = stringResource(Res.string.profile_registration_date)
|
||||
val jan = stringResource(Res.string.month_name_jan)
|
||||
val feb = stringResource(Res.string.month_name_feb)
|
||||
val mar = stringResource(Res.string.month_name_mar)
|
||||
val apr = stringResource(Res.string.month_name_apr)
|
||||
val may = stringResource(Res.string.month_name_may)
|
||||
val jun = stringResource(Res.string.month_name_jun)
|
||||
val jul = stringResource(Res.string.month_name_jul)
|
||||
val aug = stringResource(Res.string.month_name_aug)
|
||||
val sep = stringResource(Res.string.month_name_sep)
|
||||
val oct = stringResource(Res.string.month_name_oct)
|
||||
val nov = stringResource(Res.string.month_name_nov)
|
||||
val dec = stringResource(Res.string.month_name_dec)
|
||||
|
||||
return remember(
|
||||
template, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec,
|
||||
) {
|
||||
val months = listOf(jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec)
|
||||
RegistrationDateFormatStrings(
|
||||
template = template,
|
||||
monthName = { monthNumber -> months.getOrElse(monthNumber - 1) { "" } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun parseRegistrationLocalDate(iso: String): LocalDate? {
|
||||
runCatching { Instant.parse(iso) }.getOrNull()?.let {
|
||||
return it.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
runCatching { LocalDateTime.parse(iso).date }.getOrNull()?.let { return it }
|
||||
return runCatching { LocalDate.parse(iso.substringBefore('T')) }.getOrNull()
|
||||
}
|
||||
|
||||
/** Formats an ISO registration timestamp as e.g. "13 june 2026" (localized). */
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun formatProfileRegistrationDate(
|
||||
createdAtIso: String?,
|
||||
strings: RegistrationDateFormatStrings,
|
||||
): String? {
|
||||
val iso = createdAtIso?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val date = parseRegistrationLocalDate(iso) ?: return iso
|
||||
return formatFromXmlTemplate(
|
||||
strings.template,
|
||||
date.day,
|
||||
strings.monthName(date.month.number),
|
||||
date.year,
|
||||
)
|
||||
}
|
||||
@@ -4,5 +4,6 @@ enum class HapticFeedbackEvent {
|
||||
ProfileOpened,
|
||||
ProfileClosed,
|
||||
MessageSent,
|
||||
ContextMenuOpened
|
||||
ContextMenuOpened,
|
||||
SelectionModeEntered,
|
||||
}
|
||||
@@ -20,6 +20,7 @@ CREATE TABLE conversation (
|
||||
lastMessagePreview TEXT,
|
||||
unreadCount INTEGER NOT NULL DEFAULT 0,
|
||||
updatedAt TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (instanceId, id)
|
||||
);
|
||||
|
||||
@@ -157,6 +158,11 @@ deleteMessageById:
|
||||
DELETE FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND id = ?;
|
||||
|
||||
updateMessageSendStatusByClientMessageId:
|
||||
UPDATE message
|
||||
SET sendStatus = ?
|
||||
WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ?;
|
||||
|
||||
upsertMessage:
|
||||
INSERT OR REPLACE INTO message(
|
||||
instanceId,
|
||||
@@ -178,6 +184,16 @@ UPDATE message
|
||||
SET deletedFlag = 1
|
||||
WHERE instanceId = ? AND id = ? AND conversationId = ?;
|
||||
|
||||
selectUnreadPublicMessageIds:
|
||||
SELECT id
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
|
||||
|
||||
markPublicMessagesRead:
|
||||
UPDATE message
|
||||
SET isRead = 1
|
||||
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0;
|
||||
|
||||
deleteAllMessagesForInstance:
|
||||
DELETE FROM message WHERE instanceId = ?;
|
||||
|
||||
@@ -209,6 +225,67 @@ FROM conversation
|
||||
WHERE instanceId = ?
|
||||
ORDER BY updatedAt DESC;
|
||||
|
||||
selectActiveDmConversationsForInstance:
|
||||
SELECT *
|
||||
FROM conversation
|
||||
WHERE instanceId = ? AND type = 'dm' AND archived = 0
|
||||
ORDER BY updatedAt DESC;
|
||||
|
||||
selectConversationById:
|
||||
SELECT *
|
||||
FROM conversation
|
||||
WHERE instanceId = ? AND id = ?
|
||||
LIMIT 1;
|
||||
|
||||
countNonDeletedMessagesForConversation:
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0;
|
||||
|
||||
countPendingMessagesForConversation:
|
||||
SELECT COUNT(*)
|
||||
FROM message
|
||||
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0;
|
||||
|
||||
deleteConversationById:
|
||||
DELETE FROM conversation
|
||||
WHERE instanceId = ? AND id = ?;
|
||||
|
||||
deleteEmptyDmConversations:
|
||||
DELETE FROM conversation
|
||||
WHERE instanceId = ? AND type = 'dm' AND id NOT IN (
|
||||
SELECT DISTINCT conversationId FROM message
|
||||
WHERE instanceId = ? AND deletedFlag = 0
|
||||
);
|
||||
|
||||
selectForeignPendingMessages:
|
||||
SELECT *
|
||||
FROM message
|
||||
WHERE instanceId = ? AND id < 0 AND deletedFlag = 0 AND userId != ?;
|
||||
|
||||
selectAllPendingMessagesForInstance:
|
||||
SELECT *
|
||||
FROM message
|
||||
WHERE instanceId = ? AND id < 0 AND deletedFlag = 0;
|
||||
|
||||
deleteAllPendingMessagesForInstance:
|
||||
DELETE FROM message
|
||||
WHERE instanceId = ? AND id < 0;
|
||||
|
||||
deletePendingMessagesNotFromUser:
|
||||
DELETE FROM message
|
||||
WHERE instanceId = ? AND id < 0 AND userId != ?;
|
||||
|
||||
updateConversationArchived:
|
||||
UPDATE conversation
|
||||
SET archived = ?
|
||||
WHERE instanceId = ? AND id = ?;
|
||||
|
||||
updateConversationUnreadCount:
|
||||
UPDATE conversation
|
||||
SET unreadCount = ?
|
||||
WHERE instanceId = ? AND id = ?;
|
||||
|
||||
upsertConversation:
|
||||
INSERT OR REPLACE INTO conversation(
|
||||
instanceId,
|
||||
@@ -219,8 +296,9 @@ INSERT OR REPLACE INTO conversation(
|
||||
lastMessageId,
|
||||
lastMessagePreview,
|
||||
unreadCount,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
updatedAt,
|
||||
archived
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- outbox
|
||||
selectPendingOutboxForInstance:
|
||||
|
||||
+17
@@ -21,3 +21,20 @@ actual suspend fun wipeFromChatCacheDirectory() {
|
||||
NSFileManager.defaultManager.removeItemAtPath(path, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual suspend fun wipeAttachmentCacheDirectories() {
|
||||
withContext(Dispatchers.Default) {
|
||||
val url = NSFileManager.defaultManager.URLForDirectory(
|
||||
directory = NSCachesDirectory,
|
||||
inDomain = NSUserDomainMask,
|
||||
appropriateForURL = null,
|
||||
create = false,
|
||||
error = null,
|
||||
) ?: return@withContext
|
||||
val base = url.path ?: return@withContext
|
||||
listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name ->
|
||||
NSFileManager.defaultManager.removeItemAtPath("$base/$name", error = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.fromchat.legal
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
|
||||
@Composable
|
||||
actual fun Markdown(
|
||||
content: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
MarkdownPlain(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
onLinkClick = { uriHandler.openUri(it) },
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
|
||||
val style: UIImpactFeedbackStyle = when (ordinal) {
|
||||
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
||||
HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy
|
||||
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
|
||||
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
|
||||
}
|
||||
val generator = UIImpactFeedbackGenerator(style)
|
||||
|
||||
@@ -44,6 +44,7 @@ krypto = "4.0.10"
|
||||
sqldelight = "2.3.2"
|
||||
livekitAndroid = "2.25.2"
|
||||
livekitAndroidComposeComponents = "2.3.0"
|
||||
markdownRendererM3 = "0.41.0"
|
||||
bouncycastle = "1.79"
|
||||
|
||||
[libraries]
|
||||
@@ -115,6 +116,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
|
||||
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
|
||||
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" }
|
||||
livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" }
|
||||
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRendererM3" }
|
||||
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
|
||||
|
||||
[plugins]
|
||||
|
||||
@@ -17,9 +17,9 @@ kotlin {
|
||||
}
|
||||
|
||||
listOf(
|
||||
iosX64(),
|
||||
iosArm64(),
|
||||
iosSimulatorArm64()
|
||||
iosSimulatorArm64(),
|
||||
iosX64(),
|
||||
).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "shared"
|
||||
|
||||
@@ -55,7 +55,12 @@ import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -120,17 +125,45 @@ actual fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
|
||||
@SuppressLint("ComposableNaming")
|
||||
@Composable
|
||||
actual fun ToggleNavScrimEffect(enabled: Boolean) {
|
||||
val context = (LocalContext() as Activity)
|
||||
LaunchedEffect(enabled) {
|
||||
runCatching {
|
||||
val window = context.window
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = enabled
|
||||
val context = LocalContext.current as Activity
|
||||
DisposableEffect(enabled) {
|
||||
val window = context.window
|
||||
val previousContrastEnforced = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = enabled
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && previousContrastEnforced != null) {
|
||||
window.isNavigationBarContrastEnforced = previousContrastEnforced
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun DialogEdgeToEdgeEffect() {
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
val window = (view.parent as? DialogWindowProvider)?.window ?: return@SideEffect
|
||||
runCatching {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val screenWidth: Int inline get() = Resources.getSystem().displayMetrics.widthPixels
|
||||
@Suppress("unused")
|
||||
val screenHeight: Int inline get() = Resources.getSystem().displayMetrics.heightPixels
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.conditional
|
||||
|
||||
object CategoryDefaults {
|
||||
val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp)
|
||||
@@ -42,6 +43,7 @@ private fun CategoryBase(
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = CategoryDefaults.containerColor,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
roundedCorners: Boolean = true,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
@@ -70,7 +72,7 @@ private fun CategoryBase(
|
||||
modifier = modifier
|
||||
.padding(margin)
|
||||
.fillMaxWidth(),
|
||||
shape = CategoryDefaults.shape,
|
||||
shape = if (roundedCorners) CategoryDefaults.shape else RoundedCornerShape(0.dp),
|
||||
colors = cardColors(containerColor = backgroundColor),
|
||||
content = content
|
||||
)
|
||||
@@ -84,6 +86,7 @@ fun LazyItemScope.Category(
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = CategoryDefaults.containerColor,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
roundedCorners: Boolean = true,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
CategoryBase(
|
||||
@@ -92,6 +95,7 @@ fun LazyItemScope.Category(
|
||||
margin = margin,
|
||||
containerColor = containerColor,
|
||||
backgroundColor = backgroundColor,
|
||||
roundedCorners = roundedCorners,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -103,6 +107,7 @@ fun ColumnScope.Category(
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = CategoryDefaults.containerColor,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
roundedCorners: Boolean = true,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
CategoryBase(
|
||||
@@ -111,6 +116,7 @@ fun ColumnScope.Category(
|
||||
margin = margin,
|
||||
containerColor = containerColor,
|
||||
backgroundColor = backgroundColor,
|
||||
roundedCorners = roundedCorners,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -128,6 +134,7 @@ fun LazyListScope.Category(
|
||||
title: String? = null,
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color? = null,
|
||||
roundedCorners: Boolean = true,
|
||||
content: CategoryScope.() -> Unit
|
||||
) {
|
||||
val scope = CategoryScope().apply(content)
|
||||
@@ -165,26 +172,28 @@ fun LazyListScope.Category(
|
||||
start = margin.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = margin.calculateEndPadding(LocalLayoutDirection.current)
|
||||
)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart =
|
||||
if (index == 0)
|
||||
CategoryDefaults.shape.topStart
|
||||
else CornerSize(0.dp),
|
||||
topEnd =
|
||||
if (index == 0)
|
||||
CategoryDefaults.shape.topEnd
|
||||
else CornerSize(0.dp),
|
||||
bottomStart =
|
||||
if (index == items.lastIndex)
|
||||
CategoryDefaults.shape.bottomStart
|
||||
else CornerSize(0.dp),
|
||||
bottomEnd =
|
||||
if (index == items.lastIndex)
|
||||
CategoryDefaults.shape.bottomEnd
|
||||
else CornerSize(0.dp)
|
||||
.conditional(roundedCorners) {
|
||||
clip(
|
||||
RoundedCornerShape(
|
||||
topStart =
|
||||
if (index == 0)
|
||||
CategoryDefaults.shape.topStart
|
||||
else CornerSize(0.dp),
|
||||
topEnd =
|
||||
if (index == 0)
|
||||
CategoryDefaults.shape.topEnd
|
||||
else CornerSize(0.dp),
|
||||
bottomStart =
|
||||
if (index == items.lastIndex)
|
||||
CategoryDefaults.shape.bottomStart
|
||||
else CornerSize(0.dp),
|
||||
bottomEnd =
|
||||
if (index == items.lastIndex)
|
||||
CategoryDefaults.shape.bottomEnd
|
||||
else CornerSize(0.dp)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
composableItem()
|
||||
}
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
package com.pr0gramm3r101.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animate
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -28,6 +34,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
@@ -66,30 +74,49 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.pr0gramm3r101.utils.conditional
|
||||
import com.pr0gramm3r101.utils.currentWindowSize
|
||||
import com.pr0gramm3r101.utils.invoke
|
||||
import com.pr0gramm3r101.utils.left
|
||||
import com.pr0gramm3r101.utils.link
|
||||
import com.pr0gramm3r101.utils.plus
|
||||
import com.pr0gramm3r101.utils.right
|
||||
import com.pr0gramm3r101.utils.scaleOnPress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import tech.annexflow.constraintlayout.compose.ConstraintLayout
|
||||
@@ -102,6 +129,12 @@ val LocalDividerThickness = compositionLocalOf<Dp?> { null }
|
||||
val LocalBeforeDividerRadius = compositionLocalOf<Dp?> { null }
|
||||
val LocalContainerColor = compositionLocalOf<Color?> { null }
|
||||
|
||||
enum class ListItemPosition {
|
||||
START,
|
||||
MIDDLE,
|
||||
END,
|
||||
}
|
||||
|
||||
object ListItemDefaults {
|
||||
val dividerColor @Composable get() = DividerDefaults.color
|
||||
val thickness = DividerDefaults.Thickness
|
||||
@@ -109,12 +142,408 @@ object ListItemDefaults {
|
||||
val containerColor = Color.Transparent
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun listItemClipShape(
|
||||
position: ListItemPosition,
|
||||
groupItemCount: Int? = null,
|
||||
cornerShape: CornerBasedShape = CategoryDefaults.shape,
|
||||
beforeDividerRadius: Dp = ListItemDefaults.beforeDividerRadius,
|
||||
): Shape {
|
||||
val useGroupShape = position != ListItemPosition.MIDDLE || groupItemCount != null
|
||||
if (!useGroupShape) {
|
||||
return RoundedCornerShape(beforeDividerRadius)
|
||||
}
|
||||
val solo = groupItemCount == 1
|
||||
val roundTop = position == ListItemPosition.START || solo
|
||||
val roundBottom = position == ListItemPosition.END || solo
|
||||
val innerRadius = CornerSize(beforeDividerRadius)
|
||||
return RoundedCornerShape(
|
||||
topStart = if (roundTop) cornerShape.topStart else innerRadius,
|
||||
topEnd = if (roundTop) cornerShape.topEnd else innerRadius,
|
||||
bottomStart = if (roundBottom) cornerShape.bottomStart else innerRadius,
|
||||
bottomEnd = if (roundBottom) cornerShape.bottomEnd else innerRadius,
|
||||
)
|
||||
}
|
||||
|
||||
fun listItemPositionInGroup(index: Int, count: Int): ListItemPosition {
|
||||
require(count > 0)
|
||||
return when {
|
||||
index == 0 -> ListItemPosition.START
|
||||
index == count - 1 -> ListItemPosition.END
|
||||
else -> ListItemPosition.MIDDLE
|
||||
}
|
||||
}
|
||||
|
||||
class ListItemContextMenuScope internal constructor(
|
||||
private val dismiss: () -> Unit,
|
||||
) {
|
||||
internal val items = mutableListOf<ListItemContextMenuEntry>()
|
||||
|
||||
fun item(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
items.add(ListItemContextMenuEntry(icon, label, onClick))
|
||||
}
|
||||
|
||||
fun close() {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ListItemContextMenuEntry(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
private val listItemContextMenuShape = RoundedCornerShape(16.dp)
|
||||
private val listItemContextMenuItemShape = RoundedCornerShape(12.dp)
|
||||
|
||||
@Composable
|
||||
private fun ListItemContextMenuPopup(
|
||||
open: Boolean,
|
||||
position: IntOffset,
|
||||
anchorPositionInRoot: Offset,
|
||||
onDismiss: () -> Unit,
|
||||
menuContent: ListItemContextMenuScope.() -> Unit,
|
||||
) {
|
||||
var shouldShowPopup by remember { mutableStateOf(open) }
|
||||
val animationProgress = remember { mutableFloatStateOf(0f) }
|
||||
val latestMenuContent by rememberUpdatedState(menuContent)
|
||||
val windowSize = currentWindowSize()
|
||||
val screenWidthPx = windowSize.width
|
||||
val screenHeightPx = windowSize.height
|
||||
|
||||
LaunchedEffect(open) {
|
||||
if (!open) {
|
||||
animate(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
) { value, _ ->
|
||||
animationProgress.floatValue = value
|
||||
}
|
||||
shouldShowPopup = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(open) {
|
||||
if (open) {
|
||||
shouldShowPopup = true
|
||||
animationProgress.floatValue = 0f
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldShowPopup) return
|
||||
|
||||
val scope = remember(onDismiss) { ListItemContextMenuScope(onDismiss) }
|
||||
scope.items.clear()
|
||||
latestMenuContent(scope)
|
||||
val items = scope.items.toList()
|
||||
if (items.isEmpty()) {
|
||||
if (open) onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
var measuredSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
|
||||
val looseConstraints = Constraints(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxWidth = screenWidthPx,
|
||||
maxHeight = screenHeightPx,
|
||||
)
|
||||
val placeables = subcompose("measure") {
|
||||
ListItemContextMenuContent(
|
||||
items = items,
|
||||
animated = false,
|
||||
withShadow = false,
|
||||
modifier = Modifier.graphicsLayer(alpha = 0f),
|
||||
)
|
||||
}.map { it.measure(looseConstraints) }
|
||||
val p = placeables.firstOrNull()
|
||||
if (p != null && measuredSize == IntSize.Zero) {
|
||||
measuredSize = IntSize(p.width, p.height)
|
||||
}
|
||||
layout(0, 0) {
|
||||
placeables.forEach { it.placeRelative(-10000, -10000) }
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val paddingPx = with(density) { 16.dp.toPx().toInt() }
|
||||
val rightEdge = screenWidthPx - paddingPx
|
||||
val bottomEdge = screenHeightPx - paddingPx
|
||||
|
||||
val adjustedOffset = remember(
|
||||
measuredSize,
|
||||
position,
|
||||
anchorPositionInRoot,
|
||||
rightEdge,
|
||||
bottomEdge,
|
||||
paddingPx,
|
||||
) {
|
||||
if (measuredSize == IntSize.Zero) {
|
||||
position
|
||||
} else {
|
||||
var screenX = anchorPositionInRoot.x.toInt() + position.x
|
||||
var screenY = anchorPositionInRoot.y.toInt() + position.y
|
||||
if (screenX + measuredSize.width > rightEdge) screenX = rightEdge - measuredSize.width
|
||||
if (screenY + measuredSize.height > bottomEdge) screenY = bottomEdge - measuredSize.height
|
||||
if (screenX < paddingPx) screenX = paddingPx
|
||||
if (screenY < paddingPx) screenY = paddingPx
|
||||
IntOffset(
|
||||
screenX - anchorPositionInRoot.x.toInt(),
|
||||
screenY - anchorPositionInRoot.y.toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val transformOriginX = if (measuredSize.width > 0) {
|
||||
((position.x - adjustedOffset.x).toFloat() / measuredSize.width).coerceIn(0f, 1f)
|
||||
} else 0f
|
||||
val transformOriginY = if (measuredSize.height > 0) {
|
||||
((position.y - adjustedOffset.y).toFloat() / measuredSize.height).coerceIn(0f, 1f)
|
||||
} else 0f
|
||||
|
||||
LaunchedEffect(measuredSize) {
|
||||
if (measuredSize != IntSize.Zero) {
|
||||
animate(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
) { value, _ ->
|
||||
animationProgress.floatValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (measuredSize == IntSize.Zero) return
|
||||
|
||||
val scale = 0.5f + 0.5f * animationProgress.floatValue
|
||||
val alpha = animationProgress.floatValue
|
||||
|
||||
Popup(
|
||||
onDismissRequest = onDismiss,
|
||||
alignment = Alignment.TopStart,
|
||||
offset = adjustedOffset,
|
||||
properties = PopupProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
clippingEnabled = false,
|
||||
),
|
||||
) {
|
||||
ListItemContextMenuContent(
|
||||
items = items,
|
||||
animated = true,
|
||||
scale = scale,
|
||||
alpha = alpha,
|
||||
transformOriginX = transformOriginX,
|
||||
transformOriginY = transformOriginY,
|
||||
onItemClick = { entry ->
|
||||
entry.onClick()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListItemContextMenuContent(
|
||||
items: List<ListItemContextMenuEntry>,
|
||||
animated: Boolean,
|
||||
withShadow: Boolean = true,
|
||||
scale: Float = 1f,
|
||||
alpha: Float = 1f,
|
||||
transformOriginX: Float = 0f,
|
||||
transformOriginY: Float = 0f,
|
||||
modifier: Modifier = Modifier,
|
||||
onItemClick: (ListItemContextMenuEntry) -> Unit = { it.onClick() },
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val shadowElevationPx = if (withShadow) with(density) { 12.dp.toPx() } else 0f
|
||||
val menuColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val baseModifier = modifier.width(IntrinsicSize.Max)
|
||||
val containerModifier = if (animated) {
|
||||
baseModifier.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
alpha = alpha,
|
||||
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
|
||||
shadowElevation = shadowElevationPx,
|
||||
shape = listItemContextMenuShape,
|
||||
clip = true,
|
||||
)
|
||||
} else {
|
||||
baseModifier.graphicsLayer(
|
||||
shadowElevation = shadowElevationPx,
|
||||
shape = listItemContextMenuShape,
|
||||
clip = true,
|
||||
)
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||
Box(modifier = containerModifier) {
|
||||
Box(modifier = Modifier.matchParentSize().background(menuColor, listItemContextMenuShape))
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items.forEach { entry ->
|
||||
ListItemContextMenuRow(
|
||||
icon = entry.icon,
|
||||
label = entry.label,
|
||||
onClick = { onItemClick(entry) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListItemContextMenuRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
.clip(listItemContextMenuItemShape)
|
||||
.scaleOnPress(
|
||||
scale = 0.96f,
|
||||
onClick = onClick,
|
||||
indication = LocalIndication.current,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium,
|
||||
),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContextMenuPressable(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
openMenuOnTap: Boolean = false,
|
||||
pressScale: Float = 0.96f,
|
||||
onContextMenuOpen: (() -> Unit)? = null,
|
||||
contextMenu: ListItemContextMenuScope.() -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (!enabled) {
|
||||
Box(modifier = modifier) { content() }
|
||||
return
|
||||
}
|
||||
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var menuPosition by remember { mutableStateOf(IntOffset.Zero) }
|
||||
var anchorPositionInRoot by remember { mutableStateOf(Offset.Zero) }
|
||||
val latestContextMenu by rememberUpdatedState(contextMenu)
|
||||
val scaleTarget = if (isPressed && !menuOpen) pressScale else 1f
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = scaleTarget,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
visibilityThreshold = 0.001f,
|
||||
label = "contextMenuPressableScale",
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
anchorPositionInRoot = coordinates.positionInRoot()
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
transformOrigin = TransformOrigin.Center,
|
||||
)
|
||||
.pointerInput(latestContextMenu, openMenuOnTap) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
isPressed = false
|
||||
}
|
||||
},
|
||||
onTap = {
|
||||
if (openMenuOnTap) {
|
||||
onContextMenuOpen?.invoke()
|
||||
menuPosition = IntOffset.Zero
|
||||
menuOpen = true
|
||||
}
|
||||
},
|
||||
onLongPress = { localOffset ->
|
||||
onContextMenuOpen?.invoke()
|
||||
menuPosition = IntOffset(
|
||||
localOffset.x.toInt(),
|
||||
localOffset.y.toInt(),
|
||||
)
|
||||
menuOpen = true
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
ListItemContextMenuPopup(
|
||||
open = menuOpen,
|
||||
position = menuPosition,
|
||||
anchorPositionInRoot = anchorPositionInRoot,
|
||||
onDismiss = { menuOpen = false },
|
||||
menuContent = latestContextMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItem(
|
||||
modifier: Modifier = Modifier,
|
||||
bodyModifier: Modifier = Modifier,
|
||||
headline: String,
|
||||
supportingText: String? = null,
|
||||
supportingSlot: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
|
||||
containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor,
|
||||
@@ -124,12 +553,43 @@ fun ListItem(
|
||||
dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness,
|
||||
dividerAnimated: Boolean = false,
|
||||
beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius,
|
||||
position: ListItemPosition = ListItemPosition.MIDDLE,
|
||||
groupItemCount: Int? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
bodyOnClick: (() -> Unit)? = null,
|
||||
leadingAndBodyShared: Boolean = false,
|
||||
bottomContent: (@Composable () -> Unit)? = null
|
||||
bottomContent: (@Composable () -> Unit)? = null,
|
||||
onContextMenuOpen: (() -> Unit)? = null,
|
||||
contextMenu: (ListItemContextMenuScope.() -> Unit)? = null,
|
||||
) {
|
||||
Column {
|
||||
val showDivider = divider && position != ListItemPosition.END
|
||||
val clipShape = listItemClipShape(
|
||||
position = position,
|
||||
groupItemCount = groupItemCount,
|
||||
beforeDividerRadius = beforeDividerRadius,
|
||||
)
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var menuPosition by remember { mutableStateOf(IntOffset.Zero) }
|
||||
var anchorPositionInRoot by remember { mutableStateOf(Offset.Zero) }
|
||||
val latestContextMenu by rememberUpdatedState(contextMenu)
|
||||
val hasContextMenu = contextMenu != null
|
||||
val scaleTarget = if (isPressed && !menuOpen) 0.96f else 1f
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = scaleTarget,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
),
|
||||
visibilityThreshold = 0.001f,
|
||||
label = "listItemScale",
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
anchorPositionInRoot = coordinates.positionInRoot()
|
||||
},
|
||||
) {
|
||||
@Composable
|
||||
fun ProvideStyle(
|
||||
content: @Composable BoxScope?.() -> Unit
|
||||
@@ -149,12 +609,44 @@ fun ListItem(
|
||||
}
|
||||
|
||||
ProvideStyle {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
transformOrigin = TransformOrigin.Center,
|
||||
)
|
||||
.conditional(hasContextMenu) {
|
||||
pointerInput(latestContextMenu, onClick) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
try {
|
||||
awaitRelease()
|
||||
} finally {
|
||||
isPressed = false
|
||||
}
|
||||
},
|
||||
onTap = { onClick?.invoke() },
|
||||
onLongPress = { localOffset ->
|
||||
onContextMenuOpen?.invoke()
|
||||
menuPosition = IntOffset(
|
||||
localOffset.x.toInt(),
|
||||
localOffset.y.toInt(),
|
||||
)
|
||||
menuOpen = true
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(beforeDividerRadius))
|
||||
.clip(clipShape)
|
||||
.background(containerColor)
|
||||
.conditional(onClick != null) {
|
||||
.conditional(onClick != null && !hasContextMenu) {
|
||||
clickable(onClick = onClick!!)
|
||||
}
|
||||
.then(modifier)
|
||||
@@ -187,7 +679,12 @@ fun ListItem(
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = { if (supportingText != null) Text(supportingText) },
|
||||
supportingContent = {
|
||||
when {
|
||||
supportingSlot != null -> supportingSlot()
|
||||
supportingText != null -> Text(supportingText)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.constrainAs(listItem) {
|
||||
top link parent.top
|
||||
@@ -203,7 +700,7 @@ fun ListItem(
|
||||
|
||||
width = Dimension.fillToConstraints
|
||||
}
|
||||
.conditional(bodyOnClick != null && !leadingAndBodyShared) {
|
||||
.conditional(bodyOnClick != null && !leadingAndBodyShared && !hasContextMenu) {
|
||||
Modifier.clickable(onClick = bodyOnClick!!)
|
||||
}
|
||||
.then(bodyModifier),
|
||||
@@ -227,7 +724,7 @@ fun ListItem(
|
||||
else parent.right
|
||||
width = Dimension.fillToConstraints
|
||||
}
|
||||
.conditional(bodyOnClick != null) {
|
||||
.conditional(bodyOnClick != null && !hasContextMenu) {
|
||||
Modifier.clickable(onClick = bodyOnClick!!)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -273,12 +770,13 @@ fun ListItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
dividerAnimated && divider -> {
|
||||
dividerAnimated && showDivider -> {
|
||||
AnimatedVisibility(
|
||||
visible = divider,
|
||||
visible = showDivider,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
@@ -288,13 +786,23 @@ fun ListItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
divider -> {
|
||||
showDivider -> {
|
||||
HorizontalDivider(
|
||||
color = dividerColor,
|
||||
thickness = dividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContextMenu) {
|
||||
ListItemContextMenuPopup(
|
||||
open = menuOpen,
|
||||
position = menuPosition,
|
||||
anchorPositionInRoot = anchorPositionInRoot,
|
||||
onDismiss = { menuOpen = false },
|
||||
menuContent = { latestContextMenu?.invoke(this) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +819,11 @@ inline fun SwitchListItem(
|
||||
dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
|
||||
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
|
||||
dividerAnimated: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
enabled: Boolean = true,
|
||||
position: ListItemPosition = ListItemPosition.MIDDLE,
|
||||
groupItemCount: Int? = null,
|
||||
noinline onContextMenuOpen: (() -> Unit)? = null,
|
||||
noinline contextMenu: (ListItemContextMenuScope.() -> Unit)? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
ListItem(
|
||||
@@ -341,7 +853,11 @@ inline fun SwitchListItem(
|
||||
dividerColor = dividerColor,
|
||||
dividerThickness = dividerThickness,
|
||||
dividerAnimated = dividerAnimated,
|
||||
enabled = enabled
|
||||
enabled = enabled,
|
||||
position = position,
|
||||
groupItemCount = groupItemCount,
|
||||
onContextMenuOpen = onContextMenuOpen,
|
||||
contextMenu = contextMenu,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -359,7 +875,11 @@ inline fun SeparatedSwitchListItem(
|
||||
dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
|
||||
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
|
||||
dividerAnimated: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
enabled: Boolean = true,
|
||||
position: ListItemPosition = ListItemPosition.MIDDLE,
|
||||
groupItemCount: Int? = null,
|
||||
noinline onContextMenuOpen: (() -> Unit)? = null,
|
||||
noinline contextMenu: (ListItemContextMenuScope.() -> Unit)? = null,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
@@ -399,7 +919,11 @@ inline fun SeparatedSwitchListItem(
|
||||
dividerThickness = dividerThickness,
|
||||
dividerAnimated = dividerAnimated,
|
||||
leadingAndBodyShared = true,
|
||||
enabled = enabled
|
||||
enabled = enabled,
|
||||
position = position,
|
||||
groupItemCount = groupItemCount,
|
||||
onContextMenuOpen = onContextMenuOpen,
|
||||
contextMenu = contextMenu,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ operator fun Modifier.plus(other: Modifier) = then(other)
|
||||
@Composable
|
||||
expect fun ToggleNavScrimEffect(enabled: Boolean = false)
|
||||
|
||||
/** Configures the hosting dialog window for edge-to-edge bottom sheets (Android). */
|
||||
@Composable
|
||||
expect fun DialogEdgeToEdgeEffect()
|
||||
|
||||
// TODO fix implementation
|
||||
interface SupportClipboardManager {
|
||||
suspend fun setText(string: String)
|
||||
|
||||
@@ -16,6 +16,9 @@ actual fun Modifier.clearFocusOnKeyboardDismiss() = this
|
||||
|
||||
@Composable
|
||||
actual fun ToggleNavScrimEffect(enabled: Boolean) {}
|
||||
|
||||
@Composable
|
||||
actual fun DialogEdgeToEdgeEffect() {}
|
||||
actual val materialYouAvailable get() = false
|
||||
|
||||
inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short
|
||||
|
||||
Reference in New Issue
Block a user