Compare commits
19 Commits
7bf3db52a6
...
eccaf018cf
| Author | SHA1 | Date | |
|---|---|---|---|
| eccaf018cf | |||
| 9e5e81d5e5 | |||
| 62857da793 | |||
| d1aca8439a | |||
| 2501296d70 | |||
| ddb6207bb5 | |||
| d0fc8f2f1a | |||
| 86d42c8e10 | |||
| 91a47892f2 | |||
| c53cb87595 | |||
| 36fb8609d5 | |||
| 50b27fcbb3 | |||
| 9ddad8ec3c | |||
| 3f3dd956cb | |||
| 8c7ac53506 | |||
| 16c48992a5 | |||
| df8fbfc5d3 | |||
| 8f7544c655 | |||
| 6d379148b0 |
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.1.1"
|
val rosettaVersionName = "1.1.2"
|
||||||
val rosettaVersionCode = 13 // Increment on each release
|
val rosettaVersionCode = 14 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class KeyboardTransitionCoordinator {
|
|||||||
var keyboardHeight by mutableStateOf(0.dp)
|
var keyboardHeight by mutableStateOf(0.dp)
|
||||||
var emojiHeight by mutableStateOf(0.dp)
|
var emojiHeight by mutableStateOf(0.dp)
|
||||||
|
|
||||||
// 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji
|
// Максимальная высота клавиатуры (защищает от промежуточных значений при анимации закрытия)
|
||||||
private var maxKeyboardHeight by mutableStateOf(0.dp)
|
private var maxKeyboardHeight by mutableStateOf(0.dp)
|
||||||
|
|
||||||
// ============ Флаги видимости ============
|
// ============ Флаги видимости ============
|
||||||
@@ -84,8 +84,10 @@ class KeyboardTransitionCoordinator {
|
|||||||
currentState = TransitionState.KEYBOARD_TO_EMOJI
|
currentState = TransitionState.KEYBOARD_TO_EMOJI
|
||||||
isTransitioning = true
|
isTransitioning = true
|
||||||
|
|
||||||
// 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры)
|
// Устанавливаем emojiHeight = текущая высота клавиатуры (клавиатура ещё открыта)
|
||||||
if (maxKeyboardHeight > 0.dp) {
|
if (keyboardHeight > 0.dp) {
|
||||||
|
emojiHeight = keyboardHeight
|
||||||
|
} else if (maxKeyboardHeight > 0.dp) {
|
||||||
emojiHeight = maxKeyboardHeight
|
emojiHeight = maxKeyboardHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ class KeyboardTransitionCoordinator {
|
|||||||
if (height > 100.dp && height != keyboardHeight) {
|
if (height > 100.dp && height != keyboardHeight) {
|
||||||
keyboardHeight = height
|
keyboardHeight = height
|
||||||
|
|
||||||
// 🔥 Сохраняем максимальную высоту
|
// Обновляем maxKeyboardHeight только вверх (защита от промежуточных значений анимации)
|
||||||
if (height > maxKeyboardHeight) {
|
if (height > maxKeyboardHeight) {
|
||||||
maxKeyboardHeight = height
|
maxKeyboardHeight = height
|
||||||
}
|
}
|
||||||
@@ -244,14 +246,14 @@ class KeyboardTransitionCoordinator {
|
|||||||
emojiHeight = height
|
emojiHeight = height
|
||||||
}
|
}
|
||||||
} else if (height == 0.dp && keyboardHeight != 0.dp) {
|
} else if (height == 0.dp && keyboardHeight != 0.dp) {
|
||||||
// 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты
|
// Клавиатура закрывается.
|
||||||
|
// Если emoji уже показан (keyboard→emoji переход), НЕ трогаем emojiHeight —
|
||||||
// Восстанавливаем emojiHeight до максимальной высоты
|
// requestShowEmoji() уже установил правильное значение = текущая высота клавиатуры.
|
||||||
if (maxKeyboardHeight > 0.dp) {
|
// Восстанавливаем из maxKeyboardHeight только если emoji НЕ виден (обычное закрытие).
|
||||||
|
if (!isEmojiVisible && !isEmojiBoxVisible && maxKeyboardHeight > 0.dp) {
|
||||||
emojiHeight = maxKeyboardHeight
|
emojiHeight = maxKeyboardHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обнуляем keyboardHeight
|
|
||||||
keyboardHeight = 0.dp
|
keyboardHeight = 0.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +274,8 @@ class KeyboardTransitionCoordinator {
|
|||||||
* emojiHeight должна оставаться фиксированной!
|
* emojiHeight должна оставаться фиксированной!
|
||||||
*/
|
*/
|
||||||
fun syncHeights() {
|
fun syncHeights() {
|
||||||
// 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji
|
// Синхронизируем только вверх — при закрытии клавиатуры промежуточные значения
|
||||||
|
// не должны уменьшать emojiHeight. Точная высота ставится в requestShowEmoji().
|
||||||
if (keyboardHeight > 100.dp && keyboardHeight > emojiHeight) {
|
if (keyboardHeight > 100.dp && keyboardHeight > emojiHeight) {
|
||||||
emojiHeight = keyboardHeight
|
emojiHeight = keyboardHeight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -676,6 +676,7 @@ fun MainScreen(
|
|||||||
prefsManager
|
prefsManager
|
||||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||||
.collectAsState(initial = "avatar")
|
.collectAsState(initial = "avatar")
|
||||||
|
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
|
||||||
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||||
|
|
||||||
// AvatarRepository для работы с аватарами
|
// AvatarRepository для работы с аватарами
|
||||||
@@ -894,13 +895,18 @@ fun MainScreen(
|
|||||||
isVisible = isThemeVisible,
|
isVisible = isThemeVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 2
|
layer = 2,
|
||||||
|
deferToChildren = true
|
||||||
) {
|
) {
|
||||||
ThemeScreen(
|
ThemeScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
currentThemeMode = themeMode,
|
currentThemeMode = themeMode,
|
||||||
|
currentWallpaperId = chatWallpaperId,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||||
onThemeModeChange = onThemeModeChange
|
onThemeModeChange = onThemeModeChange,
|
||||||
|
onWallpaperChange = { wallpaperId ->
|
||||||
|
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,6 +995,7 @@ fun MainScreen(
|
|||||||
} + Screen.ChatDetail(forwardUser)
|
} + Screen.ChatDetail(forwardUser)
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
chatWallpaperId = chatWallpaperId,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
||||||
)
|
)
|
||||||
@@ -1072,6 +1079,9 @@ fun MainScreen(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
accountPrivateKey = accountPrivateKey,
|
accountPrivateKey = accountPrivateKey,
|
||||||
|
accountName = accountName,
|
||||||
|
accountUsername = accountUsername,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
|
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
|
||||||
onGroupOpened = { groupUser ->
|
onGroupOpened = { groupUser ->
|
||||||
navStack =
|
navStack =
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.PacketGroupLeave
|
|||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
@@ -27,6 +28,8 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
private val messageDao = db.messageDao()
|
private val messageDao = db.messageDao()
|
||||||
private val dialogDao = db.dialogDao()
|
private val dialogDao = db.dialogDao()
|
||||||
|
|
||||||
|
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val GROUP_PREFIX = "#group:"
|
private const val GROUP_PREFIX = "#group:"
|
||||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||||
@@ -159,6 +162,20 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
return response.members
|
return response.members
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCachedInviteInfo(groupId: String): GroupInviteInfoResult? {
|
||||||
|
val normalized = normalizeGroupId(groupId)
|
||||||
|
return inviteInfoCache[normalized]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheInviteInfo(groupId: String, status: GroupStatus, membersCount: Int) {
|
||||||
|
val normalized = normalizeGroupId(groupId)
|
||||||
|
inviteInfoCache[normalized] = GroupInviteInfoResult(
|
||||||
|
groupId = normalized,
|
||||||
|
membersCount = membersCount,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
|
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
|
||||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||||
if (groupId.isBlank()) return null
|
if (groupId.isBlank()) return null
|
||||||
@@ -176,11 +193,13 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
||||||
?: return null
|
?: return null
|
||||||
|
|
||||||
return GroupInviteInfoResult(
|
val result = GroupInviteInfoResult(
|
||||||
groupId = groupId,
|
groupId = groupId,
|
||||||
membersCount = response.membersCount.coerceAtLeast(0),
|
membersCount = response.membersCount.coerceAtLeast(0),
|
||||||
status = response.groupStatus
|
status = response.groupStatus
|
||||||
)
|
)
|
||||||
|
inviteInfoCache[groupId] = result
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createGroup(
|
suspend fun createGroup(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
|
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
|
||||||
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
||||||
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
||||||
|
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||||
@@ -100,6 +101,13 @@ class PreferencesManager(private val context: Context) {
|
|||||||
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
|
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val chatWallpaperId: Flow<String> =
|
||||||
|
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
|
||||||
|
|
||||||
|
suspend fun setChatWallpaperId(value: String) {
|
||||||
|
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
|
||||||
|
}
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
// 🔔 NOTIFICATIONS
|
// 🔔 NOTIFICATIONS
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -17,28 +17,27 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Группы и интерфейс
|
Поиск
|
||||||
- Полностью обновлен экран группы в стиле приложения (по паритету с desktop логикой)
|
- Новый полноценный экран поиска с вкладками: Чаты, Медиа, Загрузки, Файлы
|
||||||
- В участниках добавлены верификации, админ-метка и тултип администратора
|
- Медиа-сетка с загрузкой реальных фотографий из истории чатов
|
||||||
- Добавлен просмотр Encryption Key с QR-кодом
|
- Просмотр фото на весь экран со свайпом между ними из вкладки Медиа
|
||||||
- Улучшены секции Media/Files/Links: корректные пустые состояния и выравнивание медиа-сетки
|
- Вкладка Загрузки — скачанные файлы
|
||||||
|
- Вкладка Файлы — файловые вложения из всех чатов
|
||||||
|
|
||||||
Сообщения и списки
|
Группы
|
||||||
- Group Invite теперь отображается как invite-карточка вместо хэша (в чате и в chat list)
|
- Приглашения в группу теперь отображаются внутри пузыря сообщения с кнопкой действия
|
||||||
- Для групп в chat list показывается иконка и автор последнего сообщения (You/имя отправителя)
|
- Кэширование информации о приглашениях (больше нет загрузки при повторном открытии)
|
||||||
- Исправлено выравнивание превью вида "You: Photo"
|
- Кэширование участников группы для быстрого открытия списка
|
||||||
- Системные события группы (например joined the group) приведены к desktop-стилю
|
- Экран ключа шифрования переработан в стиле Telegram (12×12 identicon)
|
||||||
|
- Исправлен онлайн-статус участников — теперь онлайн только те кто реально онлайн
|
||||||
|
- Emoji-клавиатура в экране создания группы
|
||||||
|
|
||||||
Модерация групп
|
Чат и интерфейс
|
||||||
- Добавлены свайп и long-press действия по участникам (Kick)
|
- Улучшены индикаторы доставки сообщений
|
||||||
- Улучшены цвета, haptic и размеры action-кнопки; исправлен конфликт свайпа item vs экран
|
- Менеджер загрузки файлов с отображением прогресса
|
||||||
- Для групп в chat list добавлены swipe-actions: Pin, Leave, Delete
|
- Открытие файлов в профиле пользователя (локальные, загруженные, из blob)
|
||||||
|
- Новые обои для чатов
|
||||||
Синхронизация и стабильность
|
- Улучшена работа камеры и управление состоянием UI
|
||||||
- Исправлены пропуски сообщений при массовой синхронизации личных и групповых чатов
|
|
||||||
- Sync теперь не продвигает курсор батча при ошибках обработки и делает безопасные ретраи
|
|
||||||
- Исправлены кейсы, где requests зависели от состояния устройства, а не аккаунта
|
|
||||||
- Rosetta Updates и Safe исключены из requests
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -490,6 +490,42 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
|
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 🔍 SEARCH: Media, Files
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения с IMAGE вложениями (type: 0)
|
||||||
|
* Для вкладки "Media" в поиске
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account
|
||||||
|
AND attachments != '[]'
|
||||||
|
AND attachments LIKE '%"type":0%'
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getMessagesWithMedia(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения с FILE вложениями (type: 2)
|
||||||
|
* Для вкладки "Files" в поиске
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account
|
||||||
|
AND attachments != '[]'
|
||||||
|
AND attachments LIKE '%"type":2%'
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DAO для работы с диалогами */
|
/** DAO для работы с диалогами */
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class FileDownloadState(
|
||||||
|
val attachmentId: String,
|
||||||
|
val fileName: String,
|
||||||
|
val status: FileDownloadStatus,
|
||||||
|
/** 0f..1f */
|
||||||
|
val progress: Float = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class FileDownloadStatus {
|
||||||
|
QUEUED,
|
||||||
|
DOWNLOADING,
|
||||||
|
DECRYPTING,
|
||||||
|
DONE,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
object FileDownloadManager {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
/** Все активные/завершённые скачивания */
|
||||||
|
private val _downloads = MutableStateFlow<Map<String, FileDownloadState>>(emptyMap())
|
||||||
|
val downloads: StateFlow<Map<String, FileDownloadState>> = _downloads.asStateFlow()
|
||||||
|
|
||||||
|
/** Текущие Job'ы — чтобы не запускать повторно */
|
||||||
|
private val jobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
|
// ─── helpers ───
|
||||||
|
|
||||||
|
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||||
|
|
||||||
|
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
|
||||||
|
if (!isGroupStoredKey(storedKey)) return null
|
||||||
|
val encoded = storedKey.removePrefix("group:")
|
||||||
|
if (encoded.isBlank()) return null
|
||||||
|
return CryptoManager.decryptWithPassword(encoded, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBase64Payload(data: String): ByteArray? {
|
||||||
|
val raw = data.trim()
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val payload =
|
||||||
|
if (raw.startsWith("data:") && raw.contains(",")) raw.substringAfter(",")
|
||||||
|
else raw
|
||||||
|
return try {
|
||||||
|
android.util.Base64.decode(payload, android.util.Base64.DEFAULT)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── public API ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, идёт ли уже скачивание этого attachment
|
||||||
|
*/
|
||||||
|
fun isDownloading(attachmentId: String): Boolean {
|
||||||
|
val state = _downloads.value[attachmentId] ?: return false
|
||||||
|
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
||||||
|
*/
|
||||||
|
fun progressOf(attachmentId: String): Flow<FileDownloadState?> =
|
||||||
|
_downloads.map { it[attachmentId] }.distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает скачивание файла. Если уже скачивается — игнорирует.
|
||||||
|
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||||
|
*/
|
||||||
|
fun download(
|
||||||
|
context: Context,
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
fileName: String,
|
||||||
|
savedFile: File
|
||||||
|
) {
|
||||||
|
// Уже в процессе?
|
||||||
|
if (jobs[attachmentId]?.isActive == true) return
|
||||||
|
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f)
|
||||||
|
|
||||||
|
jobs[attachmentId] = scope.launch {
|
||||||
|
try {
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
|
||||||
|
|
||||||
|
// Запускаем polling прогресса из TransportManager
|
||||||
|
val progressJob = launch {
|
||||||
|
TransportManager.downloading.collect { list ->
|
||||||
|
val entry = list.find { it.id == attachmentId }
|
||||||
|
if (entry != null) {
|
||||||
|
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
||||||
|
val p = (entry.progress / 100f) * 0.8f
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
if (isGroupStoredKey(chachaKey)) {
|
||||||
|
downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
|
||||||
|
} else {
|
||||||
|
downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressJob.cancel()
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
|
||||||
|
} else {
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||||
|
} catch (_: OutOfMemoryError) {
|
||||||
|
System.gc()
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||||
|
} finally {
|
||||||
|
jobs.remove(attachmentId)
|
||||||
|
// Автоочистка через 5 секунд после завершения
|
||||||
|
scope.launch {
|
||||||
|
delay(5000)
|
||||||
|
_downloads.update { it - attachmentId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет скачивание
|
||||||
|
*/
|
||||||
|
fun cancel(attachmentId: String) {
|
||||||
|
jobs[attachmentId]?.cancel()
|
||||||
|
jobs.remove(attachmentId)
|
||||||
|
_downloads.update { it - attachmentId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── internal download logic (moved from FileAttachment) ───
|
||||||
|
|
||||||
|
private suspend fun downloadGroupFile(
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
fileName: String,
|
||||||
|
savedFile: File
|
||||||
|
): Boolean {
|
||||||
|
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f)
|
||||||
|
|
||||||
|
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||||
|
if (groupPassword.isNullOrBlank()) return false
|
||||||
|
|
||||||
|
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
|
||||||
|
|
||||||
|
val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f)
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
savedFile.parentFile?.mkdirs()
|
||||||
|
savedFile.writeBytes(bytes)
|
||||||
|
}
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadDirectFile(
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
fileName: String,
|
||||||
|
savedFile: File
|
||||||
|
): Boolean {
|
||||||
|
// Streaming: скачиваем во temp file
|
||||||
|
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f)
|
||||||
|
|
||||||
|
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
|
||||||
|
|
||||||
|
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MessageCrypto.decryptAttachmentFileStreaming(
|
||||||
|
tempFile,
|
||||||
|
decryptedKeyAndNonce,
|
||||||
|
savedFile
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) {
|
||||||
|
_downloads.update { map ->
|
||||||
|
map + (id to FileDownloadState(id, fileName, status, progress))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.animation.scaleOut
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
@@ -91,6 +93,7 @@ import com.rosetta.messenger.ui.chats.utils.*
|
|||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.ui.settings.ThemeWallpapers
|
||||||
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
|
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
|
||||||
import com.rosetta.messenger.utils.MediaUtils
|
import com.rosetta.messenger.utils.MediaUtils
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
|
|||||||
currentUserName: String = "",
|
currentUserName: String = "",
|
||||||
totalUnreadFromOthers: Int = 0,
|
totalUnreadFromOthers: Int = 0,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
chatWallpaperId: String = "",
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onImageViewerChanged: (Boolean) -> Unit = {}
|
onImageViewerChanged: (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// UI Theme
|
// UI Theme
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
|
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
val headerIconColor = Color.White
|
val headerIconColor = Color.White
|
||||||
@@ -633,6 +638,14 @@ fun ChatDetailScreen(
|
|||||||
isScreenActive = false
|
isScreenActive = false
|
||||||
viewModel.setDialogActive(false)
|
viewModel.setDialogActive(false)
|
||||||
}
|
}
|
||||||
|
Lifecycle.Event.ON_STOP -> {
|
||||||
|
// Hard-stop camera/picker overlays when app goes background.
|
||||||
|
// On next app open everything must start closed/off.
|
||||||
|
showInAppCamera = false
|
||||||
|
showMediaPicker = false
|
||||||
|
pendingCameraPhotoUri = null
|
||||||
|
pendingGalleryImages = emptyList()
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1806,13 +1819,28 @@ fun ChatDetailScreen(
|
|||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
|
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||||
|
// when content paddings (bottom bar/IME) change.
|
||||||
|
if (chatWallpaperResId != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = chatWallpaperResId),
|
||||||
|
contentDescription = "Chat wallpaper",
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.matchParentSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||||
// 🔥 Column структура - список сжимается когда клавиатура
|
// 🔥 Column структура - список сжимается когда клавиатура
|
||||||
// открывается
|
// открывается
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier.fillMaxSize()
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
.background(backgroundColor)
|
|
||||||
) {
|
) {
|
||||||
// Список сообщений - занимает всё доступное место
|
// Список сообщений - занимает всё доступное место
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
@@ -2487,27 +2515,6 @@ fun ChatDetailScreen(
|
|||||||
} // Закрытие Box wrapper для Scaffold content
|
} // Закрытие Box wrapper для Scaffold content
|
||||||
} // Закрытие Box
|
} // Закрытие Box
|
||||||
|
|
||||||
// 📸 Image Viewer Overlay with Telegram-style shared element animation
|
|
||||||
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
|
||||||
ImageViewerScreen(
|
|
||||||
images = imageViewerImages,
|
|
||||||
initialIndex = imageViewerInitialIndex,
|
|
||||||
privateKey = currentUserPrivateKey,
|
|
||||||
onDismiss = {
|
|
||||||
showImageViewer = false
|
|
||||||
imageViewerSourceBounds = null
|
|
||||||
imageViewerImages = emptyList()
|
|
||||||
onImageViewerChanged(false)
|
|
||||||
},
|
|
||||||
onClosingStart = {
|
|
||||||
// Сразу сбрасываем status bar при начале закрытия (до анимации)
|
|
||||||
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
sourceBounds = imageViewerSourceBounds
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Диалог подтверждения удаления чата
|
// Диалог подтверждения удаления чата
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
|
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
|
||||||
@@ -2765,7 +2772,30 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📷 In-App Camera (без системного превью!)
|
} // Закрытие Scaffold content lambda
|
||||||
|
|
||||||
|
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
|
||||||
|
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||||
|
ImageViewerScreen(
|
||||||
|
images = imageViewerImages,
|
||||||
|
initialIndex = imageViewerInitialIndex,
|
||||||
|
privateKey = currentUserPrivateKey,
|
||||||
|
onDismiss = {
|
||||||
|
showImageViewer = false
|
||||||
|
imageViewerSourceBounds = null
|
||||||
|
imageViewerImages = emptyList()
|
||||||
|
onImageViewerChanged(false)
|
||||||
|
},
|
||||||
|
onClosingStart = {
|
||||||
|
// Сразу сбрасываем status bar при начале закрытия (до анимации)
|
||||||
|
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
sourceBounds = imageViewerSourceBounds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
|
||||||
if (showInAppCamera) {
|
if (showInAppCamera) {
|
||||||
InAppCameraScreen(
|
InAppCameraScreen(
|
||||||
onDismiss = { showInAppCamera = false },
|
onDismiss = { showInAppCamera = false },
|
||||||
@@ -2827,5 +2857,5 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
} // Закрытие outer Box
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val currentDialogKey = getDialogKey(account, opponent)
|
val currentDialogKey = getDialogKey(account, opponent)
|
||||||
|
|
||||||
if (update.dialogKey == currentDialogKey) {
|
if (update.dialogKey == currentDialogKey) {
|
||||||
|
if (!isDialogActive) return@collect
|
||||||
when (update.status) {
|
when (update.status) {
|
||||||
DeliveryStatus.DELIVERED -> {
|
DeliveryStatus.DELIVERED -> {
|
||||||
// Обновляем конкретное сообщение
|
// Обновляем конкретное сообщение
|
||||||
@@ -1157,7 +1158,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
when (entity.delivered) {
|
when (entity.delivered) {
|
||||||
0 -> MessageStatus.SENDING
|
0 -> MessageStatus.SENDING
|
||||||
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
||||||
2 -> MessageStatus.SENT
|
2 -> MessageStatus.ERROR
|
||||||
3 -> MessageStatus.READ
|
3 -> MessageStatus.READ
|
||||||
else -> MessageStatus.SENT
|
else -> MessageStatus.SENT
|
||||||
},
|
},
|
||||||
@@ -2508,8 +2509,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
saveDialog(text, timestamp)
|
saveDialog(text, timestamp)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT) // Changed from ERROR
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
}
|
}
|
||||||
|
// Update error status in DB + dialog
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
saveDialog(text, timestamp)
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
@@ -3711,7 +3715,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
@@ -3941,7 +3947,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
saveDialog("\$a=Avatar", timestamp)
|
saveDialog("\$a=Avatar", timestamp)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||||
android.widget.Toast.makeText(
|
android.widget.Toast.makeText(
|
||||||
getApplication(),
|
getApplication(),
|
||||||
"Failed to send avatar: ${e.message}",
|
"Failed to send avatar: ${e.message}",
|
||||||
@@ -3949,6 +3955,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
saveDialog("\$a=Avatar", timestamp)
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
@@ -4097,7 +4105,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
content = encryptedContent,
|
content = encryptedContent,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
chachaKey = encryptedKey,
|
chachaKey = encryptedKey,
|
||||||
read = if (isFromMe) 1 else 0,
|
read = if (isFromMe && opponent == account) 1 else 0,
|
||||||
fromMe = if (isFromMe) 1 else 0,
|
fromMe = if (isFromMe) 1 else 0,
|
||||||
delivered = delivered,
|
delivered = delivered,
|
||||||
messageId = finalMessageId,
|
messageId = finalMessageId,
|
||||||
|
|||||||
@@ -442,7 +442,11 @@ fun ChatsListScreen(
|
|||||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||||
|
|
||||||
// 🔥 Пользователи, которые сейчас печатают
|
// <EFBFBD> Active downloads tracking (for header indicator)
|
||||||
|
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
|
||||||
|
val hasActiveDownloads = activeDownloads.isNotEmpty()
|
||||||
|
|
||||||
|
// <20>🔥 Пользователи, которые сейчас печатают
|
||||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
|
|
||||||
// Load dialogs when account is available
|
// Load dialogs when account is available
|
||||||
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!showRequestsScreen) {
|
if (!showRequestsScreen) {
|
||||||
|
// 📥 Animated download indicator (Telegram-style)
|
||||||
|
Box(
|
||||||
|
modifier = androidx.compose.ui.Modifier.size(48.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
|
||||||
|
isActive = hasActiveDownloads,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (protocolState ==
|
if (protocolState ==
|
||||||
@@ -3800,30 +3814,14 @@ fun DialogItemContent(
|
|||||||
// - lastMessageDelivered == 0 → часики
|
// - lastMessageDelivered == 0 → часики
|
||||||
// (отправляется)
|
// (отправляется)
|
||||||
// - lastMessageDelivered == 2 → ошибка
|
// - lastMessageDelivered == 2 → ошибка
|
||||||
when (dialog.lastMessageDelivered) {
|
val isReadByOpponent =
|
||||||
2 -> {
|
dialog.lastMessageDelivered == 3 ||
|
||||||
// ERROR - показываем иконку ошибки
|
(dialog.lastMessageDelivered == 1 &&
|
||||||
Icon(
|
dialog.lastMessageRead == 1)
|
||||||
imageVector =
|
|
||||||
TablerIcons
|
when {
|
||||||
.AlertCircle,
|
isReadByOpponent -> {
|
||||||
contentDescription =
|
// READ - две синие галочки
|
||||||
"Sending failed",
|
|
||||||
tint =
|
|
||||||
Color(
|
|
||||||
0xFFFF3B30
|
|
||||||
), // iOS красный
|
|
||||||
modifier =
|
|
||||||
Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(
|
|
||||||
modifier =
|
|
||||||
Modifier.width(4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
3 -> {
|
|
||||||
// READ (delivered=3) - две синие
|
|
||||||
// галочки
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.width(20.dp)
|
Modifier.width(20.dp)
|
||||||
@@ -3867,7 +3865,11 @@ fun DialogItemContent(
|
|||||||
Modifier.width(4.dp)
|
Modifier.width(4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
1 -> {
|
dialog.lastMessageDelivered == 2 -> {
|
||||||
|
// ERROR - не показываем статус рядом с временем,
|
||||||
|
// error badge показывается внизу справа (как в Telegram)
|
||||||
|
}
|
||||||
|
dialog.lastMessageDelivered == 1 -> {
|
||||||
// DELIVERED - одна серая галочка
|
// DELIVERED - одна серая галочка
|
||||||
Icon(
|
Icon(
|
||||||
painter =
|
painter =
|
||||||
@@ -4102,6 +4104,27 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error badge (Telegram-style) — красный кружок с "!" вместо unread badge
|
||||||
|
if (dialog.lastMessageFromMe == 1 && dialog.lastMessageDelivered == 2) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(22.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFFE53935)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "!",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
lineHeight = 13.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unread badge
|
// Unread badge
|
||||||
if (dialog.unreadCount > 0) {
|
if (dialog.unreadCount > 0) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
|
|||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen connection logs viewer.
|
|
||||||
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionLogsScreen(
|
fun ConnectionLogsScreen(
|
||||||
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Auto-scroll to bottom when new logs arrive
|
|
||||||
LaunchedEffect(logs.size) {
|
LaunchedEffect(logs.size) {
|
||||||
if (logs.isNotEmpty()) {
|
if (logs.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(logs.size - 1)
|
listState.animateScrollToItem(logs.size - 1)
|
||||||
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
|
|||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
) {
|
) {
|
||||||
// Header
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clear button
|
|
||||||
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = TablerIcons.Trash,
|
imageVector = TablerIcons.Trash,
|
||||||
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
||||||
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status bar
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs list
|
|
||||||
if (logs.isEmpty()) {
|
if (logs.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -25,10 +28,13 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
@@ -79,6 +85,7 @@ import androidx.compose.ui.graphics.toArgb
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
@@ -105,6 +112,9 @@ import com.rosetta.messenger.database.MessageEntity
|
|||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
import com.rosetta.messenger.network.MessageAttachment
|
import com.rosetta.messenger.network.MessageAttachment
|
||||||
|
import com.rosetta.messenger.network.OnlineState
|
||||||
|
import com.rosetta.messenger.network.PacketOnlineState
|
||||||
|
import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
@@ -149,6 +159,40 @@ private data class GroupSharedStats(
|
|||||||
val linksCount: Int = 0
|
val linksCount: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class GroupMembersCacheEntry(
|
||||||
|
val members: List<String>,
|
||||||
|
val memberInfoByKey: Map<String, SearchUser>,
|
||||||
|
val updatedAtMs: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private object GroupMembersMemoryCache {
|
||||||
|
private const val TTL_MS = 90_000L
|
||||||
|
private val cache = mutableMapOf<String, GroupMembersCacheEntry>()
|
||||||
|
|
||||||
|
fun getAny(key: String): GroupMembersCacheEntry? = synchronized(cache) { cache[key] }
|
||||||
|
|
||||||
|
fun getFresh(key: String): GroupMembersCacheEntry? = synchronized(cache) {
|
||||||
|
val entry = cache[key] ?: return@synchronized null
|
||||||
|
if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun put(key: String, members: List<String>, memberInfoByKey: Map<String, SearchUser>) {
|
||||||
|
if (key.isBlank()) return
|
||||||
|
synchronized(cache) {
|
||||||
|
cache[key] =
|
||||||
|
GroupMembersCacheEntry(
|
||||||
|
members = members,
|
||||||
|
memberInfoByKey = memberInfoByKey,
|
||||||
|
updatedAtMs = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(key: String) {
|
||||||
|
synchronized(cache) { cache.remove(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private data class GroupMediaItem(
|
private data class GroupMediaItem(
|
||||||
val key: String,
|
val key: String,
|
||||||
val attachment: MessageAttachment,
|
val attachment: MessageAttachment,
|
||||||
@@ -160,12 +204,14 @@ private data class GroupMediaItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
|
private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
|
||||||
private val KEY_IMAGE_COLORS = listOf(
|
|
||||||
Color(0xFFD0EBFF),
|
// Identicon colors — same palette for both themes (like Telegram)
|
||||||
Color(0xFFA5D8FF),
|
// White stays white so the pattern is visible on dark backgrounds
|
||||||
Color(0xFF74C0FC),
|
private val IDENTICON_COLORS = intArrayOf(
|
||||||
Color(0xFF4DABF7),
|
0xFFFFFFFF.toInt(),
|
||||||
Color(0xFF339AF0)
|
0xFFD0E8FF.toInt(),
|
||||||
|
0xFF228BE6.toInt(),
|
||||||
|
0xFF1971C2.toInt()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -180,8 +226,6 @@ fun GroupInfoScreen(
|
|||||||
onGroupLeft: () -> Unit = {},
|
onGroupLeft: () -> Unit = {},
|
||||||
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
|
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
@@ -242,16 +286,22 @@ fun GroupInfoScreen(
|
|||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
var showLeaveConfirm by remember { mutableStateOf(false) }
|
var showLeaveConfirm by remember { mutableStateOf(false) }
|
||||||
var isLeaving by remember { mutableStateOf(false) }
|
var isLeaving by remember { mutableStateOf(false) }
|
||||||
var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||||
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||||
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||||
var membersLoading by remember { mutableStateOf(false) }
|
var membersLoading by remember { mutableStateOf(false) }
|
||||||
var isMuted by remember { mutableStateOf(false) }
|
var isMuted by remember { mutableStateOf(false) }
|
||||||
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||||
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||||
|
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||||
|
|
||||||
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
|
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
|
||||||
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
|
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
|
||||||
|
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
|
||||||
|
val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf<String, Boolean>() }
|
||||||
|
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
|
||||||
|
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
|
||||||
|
}
|
||||||
|
|
||||||
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
|
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
|
||||||
initialValue = null,
|
initialValue = null,
|
||||||
@@ -341,16 +391,32 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshMembers() {
|
fun refreshMembers(force: Boolean = false, showLoader: Boolean = true) {
|
||||||
if (normalizedGroupId.isBlank()) return
|
if (normalizedGroupId.isBlank()) return
|
||||||
|
if (isRefreshingMembers && !force) return
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
membersLoading = true
|
if (!force) {
|
||||||
|
GroupMembersMemoryCache.getFresh(membersCacheKey)?.let { cached ->
|
||||||
|
members = cached.members
|
||||||
|
memberInfoByKey.clear()
|
||||||
|
memberInfoByKey.putAll(cached.memberInfoByKey)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null
|
||||||
|
val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
|
||||||
|
if (shouldShowLoader) membersLoading = true
|
||||||
|
isRefreshingMembers = true
|
||||||
|
try {
|
||||||
val fetchedMembers = withContext(Dispatchers.IO) {
|
val fetchedMembers = withContext(Dispatchers.IO) {
|
||||||
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
|
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
|
||||||
}
|
}
|
||||||
members = fetchedMembers.distinct()
|
val distinctMembers = fetchedMembers.distinct()
|
||||||
membersLoading = false
|
if (distinctMembers.isNotEmpty() || members.isEmpty()) {
|
||||||
|
members = distinctMembers
|
||||||
|
}
|
||||||
|
|
||||||
if (members.isEmpty()) return@launch
|
if (members.isEmpty()) return@launch
|
||||||
|
|
||||||
@@ -371,31 +437,104 @@ fun GroupInfoScreen(
|
|||||||
if (resolvedUsers.isNotEmpty()) {
|
if (resolvedUsers.isNotEmpty()) {
|
||||||
memberInfoByKey.putAll(resolvedUsers)
|
memberInfoByKey.putAll(resolvedUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GroupMembersMemoryCache.put(
|
||||||
|
key = membersCacheKey,
|
||||||
|
members = members,
|
||||||
|
memberInfoByKey = memberInfoByKey.toMap()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
if (shouldShowLoader) membersLoading = false
|
||||||
|
isRefreshingMembers = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(normalizedGroupId) {
|
LaunchedEffect(membersCacheKey) {
|
||||||
refreshMembers()
|
val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
|
||||||
|
cachedEntry?.let { cached ->
|
||||||
|
members = cached.members
|
||||||
|
memberInfoByKey.clear()
|
||||||
|
memberInfoByKey.putAll(cached.memberInfoByKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
val onlineCount by remember(members, memberInfoByKey) {
|
if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
|
||||||
|
refreshMembers(force = true, showLoader = cachedEntry == null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🟢 Subscribe to online status for group members via PacketOnlineSubscribe / PacketOnlineState
|
||||||
|
// Desktop parity: users start as OFFLINE, only become ONLINE when server sends 0x05
|
||||||
|
val onlinePacketHandler = remember<(com.rosetta.messenger.network.Packet) -> Unit>(dialogPublicKey) {
|
||||||
|
{ packet ->
|
||||||
|
val onlinePacket = packet as PacketOnlineState
|
||||||
|
onlinePacket.publicKeysState.forEach { item ->
|
||||||
|
memberOnlineStatus[item.publicKey] = (item.state == OnlineState.ONLINE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(dialogPublicKey) {
|
||||||
|
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
|
||||||
|
onDispose {
|
||||||
|
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to online status whenever members list changes
|
||||||
|
LaunchedEffect(members, currentUserPrivateKey) {
|
||||||
|
if (members.isEmpty() || currentUserPrivateKey.isBlank()) return@LaunchedEffect
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
|
||||||
|
val keysToSubscribe = members.filter { key ->
|
||||||
|
!key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (keysToSubscribe.isNotEmpty()) {
|
||||||
|
val packet = PacketOnlineSubscribe().apply {
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
keysToSubscribe.forEach { addPublicKey(it) }
|
||||||
|
}
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
||||||
|
|
||||||
|
val onlineCount by remember(members, memberOnlineStatus, normalizedCurrentUserKey) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
members.count { key -> (memberInfoByKey[key]?.online ?: 0) > 0 }
|
if (members.isEmpty()) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
val selfOnline = if (normalizedCurrentUserKey.isNotBlank()) 1 else 0
|
||||||
|
val othersOnline =
|
||||||
|
members.count { key ->
|
||||||
|
val isCurrentUser =
|
||||||
|
normalizedCurrentUserKey.isNotBlank() &&
|
||||||
|
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
|
||||||
|
!isCurrentUser && (memberOnlineStatus[key] == true)
|
||||||
|
}
|
||||||
|
selfOnline + othersOnline
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val memberItems by remember(members, memberInfoByKey, searchQuery) {
|
val memberItems by remember(members, memberInfoByKey, memberOnlineStatus, searchQuery) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val query = searchQuery.trim().lowercase()
|
val query = searchQuery.trim().lowercase()
|
||||||
members.mapIndexed { index, key ->
|
members.mapIndexed { index, key ->
|
||||||
val info = memberInfoByKey[key]
|
val info = memberInfoByKey[key]
|
||||||
|
val isOnline = memberOnlineStatus[key] == true ||
|
||||||
|
key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
|
||||||
val fallbackName = shortPublicKey(key)
|
val fallbackName = shortPublicKey(key)
|
||||||
val displayTitle =
|
val displayTitle =
|
||||||
info?.title?.takeIf { it.isNotBlank() }
|
info?.title?.takeIf { it.isNotBlank() }
|
||||||
?: info?.username?.takeIf { it.isNotBlank() }
|
?: info?.username?.takeIf { it.isNotBlank() }
|
||||||
?: fallbackName
|
?: fallbackName
|
||||||
val subtitle = when {
|
val subtitle = when {
|
||||||
(info?.online ?: 0) > 0 -> "online"
|
isOnline -> "online"
|
||||||
info?.username?.isNotBlank() == true -> "@${info.username}"
|
info?.username?.isNotBlank() == true -> "@${info.username}"
|
||||||
else -> key.take(18)
|
else -> key.take(18)
|
||||||
}
|
}
|
||||||
@@ -404,14 +543,14 @@ fun GroupInfoScreen(
|
|||||||
title = displayTitle,
|
title = displayTitle,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
verified = info?.verified ?: 0,
|
verified = info?.verified ?: 0,
|
||||||
online = (info?.online ?: 0) > 0,
|
online = isOnline,
|
||||||
isAdmin = index == 0,
|
isAdmin = index == 0,
|
||||||
searchUser = SearchUser(
|
searchUser = SearchUser(
|
||||||
publicKey = key,
|
publicKey = key,
|
||||||
title = info?.title ?: displayTitle,
|
title = info?.title ?: displayTitle,
|
||||||
username = info?.username.orEmpty(),
|
username = info?.username.orEmpty(),
|
||||||
verified = info?.verified ?: 0,
|
verified = info?.verified ?: 0,
|
||||||
online = info?.online ?: 0
|
online = if (isOnline) 1 else 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}.filter { member ->
|
}.filter { member ->
|
||||||
@@ -425,7 +564,6 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
|
||||||
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
|
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
||||||
@@ -435,6 +573,14 @@ fun GroupInfoScreen(
|
|||||||
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
if (showEncryptionPage) {
|
||||||
|
showEncryptionPage = false
|
||||||
|
} else {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (selectedTab != GroupInfoTab.MEMBERS) {
|
if (selectedTab != GroupInfoTab.MEMBERS) {
|
||||||
swipedMemberKey = null
|
swipedMemberKey = null
|
||||||
@@ -541,6 +687,7 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
isLeaving = false
|
isLeaving = false
|
||||||
if (left) {
|
if (left) {
|
||||||
|
GroupMembersMemoryCache.remove(membersCacheKey)
|
||||||
onGroupLeft()
|
onGroupLeft()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
|
||||||
@@ -580,7 +727,12 @@ fun GroupInfoScreen(
|
|||||||
if (removed) {
|
if (removed) {
|
||||||
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
|
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
|
||||||
memberInfoByKey.remove(member.publicKey)
|
memberInfoByKey.remove(member.publicKey)
|
||||||
refreshMembers()
|
GroupMembersMemoryCache.put(
|
||||||
|
key = membersCacheKey,
|
||||||
|
members = members,
|
||||||
|
memberInfoByKey = memberInfoByKey.toMap()
|
||||||
|
)
|
||||||
|
refreshMembers(force = true, showLoader = false)
|
||||||
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
|
||||||
@@ -605,7 +757,7 @@ fun GroupInfoScreen(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
encryptionKey = key
|
encryptionKey = key
|
||||||
showEncryptionDialog = true
|
showEncryptionPage = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,61 +1267,25 @@ fun GroupInfoScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEncryptionDialog) {
|
AnimatedVisibility(
|
||||||
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
visible = showEncryptionPage,
|
||||||
AlertDialog(
|
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
|
||||||
onDismissRequest = { showEncryptionDialog = false },
|
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
|
||||||
title = { Text("Encryption key") },
|
modifier = Modifier.fillMaxSize()
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
DesktopStyleKeyImage(
|
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
||||||
keyRender = encryptionKey,
|
GroupEncryptionKeyPage(
|
||||||
size = 180.dp,
|
encryptionKey = encryptionKey,
|
||||||
radius = 14.dp
|
displayLines = displayLines,
|
||||||
)
|
peerTitle = groupTitle,
|
||||||
}
|
isDarkTheme = isDarkTheme,
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
topSurfaceColor = topSurfaceColor,
|
||||||
SelectionContainer {
|
backgroundColor = backgroundColor,
|
||||||
Column {
|
onBack = { showEncryptionPage = false },
|
||||||
if (displayLines.isNotEmpty()) {
|
onCopy = {
|
||||||
displayLines.forEach { line ->
|
|
||||||
Text(
|
|
||||||
text = line,
|
|
||||||
color = secondaryText,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "This key encrypts and decrypts group messages.",
|
|
||||||
color = secondaryText,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
) {
|
|
||||||
Text("Copy", color = accentColor)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showEncryptionDialog = false }) {
|
|
||||||
Text("Close", color = secondaryText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1241,38 +1357,179 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DesktopStyleKeyImage(
|
private fun TelegramStyleIdenticon(
|
||||||
keyRender: String,
|
keyRender: String,
|
||||||
size: androidx.compose.ui.unit.Dp,
|
size: androidx.compose.ui.unit.Dp,
|
||||||
radius: androidx.compose.ui.unit.Dp = 0.dp
|
isDarkTheme: Boolean
|
||||||
) {
|
) {
|
||||||
val composition = remember(keyRender) {
|
val palette = IDENTICON_COLORS
|
||||||
buildList(64) {
|
|
||||||
val source = if (keyRender.isBlank()) "rosetta" else keyRender
|
// Convert key string to byte array, then use 2-bit grouping like Telegram's IdenticonDrawable
|
||||||
for (i in 0 until 64) {
|
val keyBytes = remember(keyRender) {
|
||||||
val code = source[i % source.length].code
|
val source = keyRender.ifBlank { "rosetta" }
|
||||||
val colorIndex = code % KEY_IMAGE_COLORS.size
|
source.toByteArray(Charsets.UTF_8)
|
||||||
add(KEY_IMAGE_COLORS[colorIndex])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(RoundedCornerShape(radius))
|
|
||||||
.background(KEY_IMAGE_COLORS.first())
|
|
||||||
) {
|
) {
|
||||||
val cells = 8
|
val cells = 12
|
||||||
val cellSize = this.size.minDimension / cells.toFloat()
|
val cellSize = this.size.minDimension / cells.toFloat()
|
||||||
for (i in 0 until 64) {
|
var bitPointer = 0
|
||||||
val row = i / cells
|
for (iy in 0 until cells) {
|
||||||
val col = i % cells
|
for (ix in 0 until cells) {
|
||||||
|
val byteIndex = (bitPointer / 8) % keyBytes.size
|
||||||
|
val bitOffset = bitPointer % 8
|
||||||
|
val value = (keyBytes[byteIndex].toInt() shr bitOffset) and 0x3
|
||||||
|
val colorIndex = kotlin.math.abs(value) % 4
|
||||||
drawRect(
|
drawRect(
|
||||||
color = composition[i],
|
color = Color(palette[colorIndex]),
|
||||||
topLeft = Offset(col * cellSize, row * cellSize),
|
topLeft = Offset(ix * cellSize, iy * cellSize),
|
||||||
size = Size(cellSize, cellSize)
|
size = Size(cellSize, cellSize)
|
||||||
)
|
)
|
||||||
|
bitPointer += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupEncryptionKeyPage(
|
||||||
|
encryptionKey: String,
|
||||||
|
displayLines: List<String>,
|
||||||
|
peerTitle: String,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
topSurfaceColor: Color,
|
||||||
|
backgroundColor: Color,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onCopy: () -> Unit
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
||||||
|
|
||||||
|
// Rosetta theme colors
|
||||||
|
val identiconBg = if (isDarkTheme) Color(0xFF111111) else Color(0xFFF2F2F7)
|
||||||
|
val bottomBg = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
|
||||||
|
val codeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF6D7883)
|
||||||
|
val descriptionColor = Color(0xFF8E8E93)
|
||||||
|
val linkColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
// Top bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(topSurfaceColor)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Encryption Key",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onCopy) {
|
||||||
|
Text(
|
||||||
|
text = "Copy",
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-half layout like Telegram
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
// Top half - Identicon image on gray background
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.background(identiconBg)
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
TelegramStyleIdenticon(
|
||||||
|
keyRender = encryptionKey,
|
||||||
|
size = 280.dp,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom half - Code + Description on white background
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.background(bottomBg)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Hex code display
|
||||||
|
SelectionContainer {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
displayLines.forEach { line ->
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
color = codeColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// Description text
|
||||||
|
Text(
|
||||||
|
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.\n\nIf they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
|
||||||
|
color = descriptionColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Learn more at rosetta.im",
|
||||||
|
color = linkColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.clickable { uriHandler.openUri("https://rosetta.im/") }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1772,14 +2029,24 @@ private fun encodeGroupKeyForDisplay(encryptKey: String): List<String> {
|
|||||||
val normalized = encryptKey.trim()
|
val normalized = encryptKey.trim()
|
||||||
if (normalized.isBlank()) return emptyList()
|
if (normalized.isBlank()) return emptyList()
|
||||||
|
|
||||||
val lines = mutableListOf<String>()
|
// Telegram-style: each char → XOR 27 → 2-char hex, grouped as:
|
||||||
normalized.chunked(16).forEach { chunk ->
|
// "ab cd ef 12 34 56 78 9a" (4 pairs + double space + 4 pairs per line)
|
||||||
val bytes = mutableListOf<String>()
|
val hexPairs = normalized.map { symbol ->
|
||||||
chunk.forEach { symbol ->
|
(symbol.code xor 27).toString(16).padStart(2, '0')
|
||||||
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
|
|
||||||
bytes.add(encoded)
|
|
||||||
}
|
}
|
||||||
lines.add(bytes.joinToString(" "))
|
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
for (lineStart in hexPairs.indices step 8) {
|
||||||
|
val lineEnd = minOf(lineStart + 8, hexPairs.size)
|
||||||
|
val linePairs = hexPairs.subList(lineStart, lineEnd)
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for ((i, pair) in linePairs.withIndex()) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(if (i % 4 == 0) " " else " ")
|
||||||
|
}
|
||||||
|
sb.append(pair)
|
||||||
|
}
|
||||||
|
lines.add(sb.toString())
|
||||||
}
|
}
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,186 @@
|
|||||||
package com.rosetta.messenger.ui.chats
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.app.Activity
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
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 androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.data.GroupRepository
|
import com.rosetta.messenger.data.GroupRepository
|
||||||
import com.rosetta.messenger.network.GroupStatus
|
import com.rosetta.messenger.network.GroupStatus
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
|
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
|
||||||
|
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||||
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
|
import com.rosetta.messenger.utils.ImageCropHelper
|
||||||
|
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
|
||||||
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
|
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
private enum class GroupSetupStep {
|
||||||
|
DETAILS,
|
||||||
|
DESCRIPTION
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||||
fun GroupSetupScreen(
|
fun GroupSetupScreen(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
accountPrivateKey: String,
|
accountPrivateKey: String,
|
||||||
|
accountName: String,
|
||||||
|
accountUsername: String,
|
||||||
|
avatarRepository: AvatarRepository? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onGroupOpened: (SearchUser) -> Unit
|
onGroupOpened: (SearchUser) -> Unit
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val view = LocalView.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val nameFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by rememberSaveable { mutableStateOf("") }
|
||||||
var description by remember { mutableStateOf("") }
|
var description by rememberSaveable { mutableStateOf("") }
|
||||||
var inviteString by remember { mutableStateOf("") }
|
var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by rememberSaveable { mutableStateOf(false) }
|
||||||
var errorText by remember { mutableStateOf<String?>(null) }
|
var errorText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showPhotoPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val coordinator = rememberKeyboardTransitionCoordinator()
|
||||||
|
var lastToggleTime by remember { mutableLongStateOf(0L) }
|
||||||
|
val toggleCooldownMs = 500L
|
||||||
|
|
||||||
|
val cropLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
|
||||||
|
val cropError = ImageCropHelper.getCropError(result)
|
||||||
|
if (croppedUri != null) {
|
||||||
|
selectedAvatarUri = croppedUri.toString()
|
||||||
|
} else if (cropError != null) {
|
||||||
|
android.widget.Toast
|
||||||
|
.makeText(context, "Failed to crop photo", android.widget.Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
|
val topSurfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
|
||||||
|
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||||
|
val sectionColor = if (isDarkTheme) Color(0xFF222224) else Color.White
|
||||||
|
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = Color(0xFF8E8E93)
|
||||||
|
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||||
|
|
||||||
|
androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
|
||||||
|
val window = (view.context as? Activity)?.window
|
||||||
|
if (window == null) {
|
||||||
|
onDispose { }
|
||||||
|
} else {
|
||||||
|
val controller = WindowCompat.getInsetsController(window, view)
|
||||||
|
val previousColor = window.statusBarColor
|
||||||
|
val previousLightIcons = controller.isAppearanceLightStatusBars
|
||||||
|
|
||||||
|
window.statusBarColor = topSurfaceColor.toArgb()
|
||||||
|
controller.isAppearanceLightStatusBars = false
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
window.statusBarColor = previousColor
|
||||||
|
controller.isAppearanceLightStatusBars = previousLightIcons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedUsername = remember(accountUsername) {
|
||||||
|
accountUsername.trim().trimStart('@')
|
||||||
|
}
|
||||||
|
val selfTitle =
|
||||||
|
remember(accountName, normalizedUsername, accountPublicKey) {
|
||||||
|
accountName.trim()
|
||||||
|
.ifBlank { normalizedUsername }
|
||||||
|
.ifBlank { shortPublicKey(accountPublicKey) }
|
||||||
|
}
|
||||||
|
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you"
|
||||||
|
|
||||||
fun openGroup(dialogPublicKey: String, groupTitle: String) {
|
fun openGroup(dialogPublicKey: String, groupTitle: String) {
|
||||||
onGroupOpened(
|
onGroupOpened(
|
||||||
@@ -77,13 +202,6 @@ fun GroupSetupScreen(
|
|||||||
description = description.trim()
|
description = description.trim()
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun joinGroup() =
|
|
||||||
GroupRepository.getInstance(context).joinGroup(
|
|
||||||
accountPublicKey = accountPublicKey,
|
|
||||||
accountPrivateKey = accountPrivateKey,
|
|
||||||
inviteString = inviteString.trim()
|
|
||||||
)
|
|
||||||
|
|
||||||
fun mapError(status: GroupStatus, fallback: String): String {
|
fun mapError(status: GroupStatus, fallback: String): String {
|
||||||
return when (status) {
|
return when (status) {
|
||||||
GroupStatus.BANNED -> "You are banned in this group"
|
GroupStatus.BANNED -> "You are banned in this group"
|
||||||
@@ -92,157 +210,615 @@ fun GroupSetupScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
fun handleBack() {
|
||||||
topBar = {
|
if (isLoading) return
|
||||||
TopAppBar(
|
errorText = null
|
||||||
title = { Text("Groups", fontWeight = FontWeight.SemiBold) },
|
if (step == GroupSetupStep.DESCRIPTION) {
|
||||||
navigationIcon = {
|
step = GroupSetupStep.DETAILS
|
||||||
TextButton(onClick = onBack) {
|
} else {
|
||||||
Text("Back")
|
onBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = ::handleBack)
|
||||||
|
|
||||||
|
val canGoNext = title.trim().isNotEmpty()
|
||||||
|
val canCreate = canGoNext && !isLoading
|
||||||
|
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val imeBottomPx = WindowInsets.ime.getBottom(density)
|
||||||
|
val imeBottomDp = with(density) { imeBottomPx.toDp() }
|
||||||
|
|
||||||
|
LaunchedEffect(step) {
|
||||||
|
if (step != GroupSetupStep.DETAILS) {
|
||||||
|
if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) {
|
||||||
|
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
|
||||||
|
if (savedPx > 0) {
|
||||||
|
coordinator.initializeEmojiHeight(with(density) { savedPx.toDp() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
|
||||||
|
var isKeyboardVisible by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(imeBottomPx) {
|
||||||
|
val currentImeHeight = with(density) { imeBottomPx.toDp() }
|
||||||
|
coordinator.updateKeyboardHeight(currentImeHeight)
|
||||||
|
isKeyboardVisible = currentImeHeight > 50.dp
|
||||||
|
if (currentImeHeight > 100.dp) {
|
||||||
|
coordinator.syncHeights()
|
||||||
|
lastStableKeyboardHeight = currentImeHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save keyboard height for emoji picker sync
|
||||||
|
LaunchedEffect(isKeyboardVisible, showEmojiKeyboard) {
|
||||||
|
if (isKeyboardVisible && !showEmojiKeyboard) {
|
||||||
|
kotlinx.coroutines.delay(350)
|
||||||
|
if (isKeyboardVisible && !showEmojiKeyboard && lastStableKeyboardHeight > 300.dp) {
|
||||||
|
val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() }
|
||||||
|
KeyboardHeightProvider.saveKeyboardHeight(context, heightPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleEmojiPicker() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastToggleTime < toggleCooldownMs || step != GroupSetupStep.DETAILS || isLoading) return
|
||||||
|
lastToggleTime = now
|
||||||
|
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
if (coordinator.isEmojiVisible) {
|
||||||
|
coordinator.requestShowKeyboard(
|
||||||
|
showKeyboard = {
|
||||||
|
nameFocusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
},
|
||||||
|
hideEmoji = { showEmojiKeyboard = false }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imeBottomPx > 0) {
|
||||||
|
coordinator.requestShowEmoji(
|
||||||
|
hideKeyboard = {
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
keyboardController?.hide()
|
||||||
|
},
|
||||||
|
showEmoji = {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
showEmojiKeyboard = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coordinator.emojiHeight == 0.dp) {
|
||||||
|
val fallbackHeight = if (lastStableKeyboardHeight > 0.dp) {
|
||||||
|
lastStableKeyboardHeight
|
||||||
|
} else {
|
||||||
|
with(density) { KeyboardHeightProvider.getSavedKeyboardHeight(context).toDp() }
|
||||||
|
}
|
||||||
|
coordinator.initializeEmojiHeight(fallbackHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.openEmojiOnly(
|
||||||
|
showEmoji = {
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
showEmojiKeyboard = true
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (step == GroupSetupStep.DETAILS) {
|
||||||
|
delay(120)
|
||||||
|
nameFocusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = backgroundColor,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = if (step == GroupSetupStep.DETAILS) "New Group" else "Group Description",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = ::handleBack) {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = topSurfaceColor,
|
||||||
|
titleContentColor = Color.White,
|
||||||
|
navigationIconContentColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (step == GroupSetupStep.DETAILS) {
|
||||||
|
AnimatedKeyboardTransition(
|
||||||
|
coordinator = coordinator,
|
||||||
|
showEmojiPicker = showEmojiKeyboard
|
||||||
|
) {
|
||||||
|
OptimizedEmojiPicker(
|
||||||
|
isVisible = true,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onEmojiSelected = { emojiCode ->
|
||||||
|
val emoji = decodeEmojiCodeToUnicode(emojiCode)
|
||||||
|
title = (title + emoji).take(80)
|
||||||
|
},
|
||||||
|
onClose = { toggleEmojiPicker() },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
// Outer Box fills the full Scaffold area (no paddingValues applied)
|
||||||
|
// so the FAB can be positioned consistently above *any* keyboard.
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Content area respects Scaffold padding (topBar + bottomBar/emoji)
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp)
|
||||||
verticalArrangement = Arrangement.Top
|
.navigationBarsPadding()
|
||||||
) {
|
) {
|
||||||
TabRow(selectedTabIndex = selectedTab) {
|
if (step == GroupSetupStep.DETAILS) {
|
||||||
Tab(
|
|
||||||
selected = selectedTab == 0,
|
|
||||||
onClick = {
|
|
||||||
selectedTab = 0
|
|
||||||
errorText = null
|
|
||||||
},
|
|
||||||
text = { Text("Create") }
|
|
||||||
)
|
|
||||||
Tab(
|
|
||||||
selected = selectedTab == 1,
|
|
||||||
onClick = {
|
|
||||||
selectedTab = 1
|
|
||||||
errorText = null
|
|
||||||
},
|
|
||||||
text = { Text("Join") }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
if (selectedTab == 0) {
|
Row(
|
||||||
OutlinedTextField(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(sectionColor)
|
||||||
|
.clickable(enabled = !isLoading) {
|
||||||
|
showPhotoPicker = true
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (!selectedAvatarUri.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(Uri.parse(selectedAvatarUri))
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Group avatar",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Camera,
|
||||||
|
contentDescription = "Set group avatar",
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
BasicTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { newValue -> title = newValue.take(80) },
|
||||||
label = { Text("Group title") },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
color = primaryTextColor,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(accentColor),
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(nameFocusRequester)
|
||||||
|
.onFocusChanged { focusState ->
|
||||||
|
if (focusState.isFocused &&
|
||||||
|
showEmojiKeyboard &&
|
||||||
|
!coordinator.isTransitioning
|
||||||
|
) {
|
||||||
|
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = RoundedCornerShape(12.dp)
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
if (title.isBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "Group name",
|
||||||
|
color = secondaryTextColor.copy(alpha = 0.88f),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Normal
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
}
|
||||||
OutlinedTextField(
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = { toggleEmojiPicker() },
|
||||||
|
enabled = !isLoading
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = if (showEmojiKeyboard || coordinator.isEmojiBoxVisible) {
|
||||||
|
TelegramIcons.Keyboard
|
||||||
|
} else {
|
||||||
|
TelegramIcons.Smile
|
||||||
|
},
|
||||||
|
contentDescription = "Emoji",
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(2.dp)
|
||||||
|
.background(accentColor.copy(alpha = 0.9f), RoundedCornerShape(1.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(22.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "1 member",
|
||||||
|
color = accentColor,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 0.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(sectionColor)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AvatarImage(
|
||||||
|
publicKey = accountPublicKey,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
size = 50.dp,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
displayName = selfTitle
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selfTitle,
|
||||||
|
color = primaryTextColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
|
||||||
|
contentDescription = "Admin",
|
||||||
|
tint = Color(0xFFF6C445),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = selfSubtitle,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "After creating the group, you can invite as many users as you need.",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 18.sp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(cardColor)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(50.dp).clip(CircleShape).background(accentColor.copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (!selectedAvatarUri.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model =
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(Uri.parse(selectedAvatarUri))
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Group avatar preview",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Photos,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title.trim(),
|
||||||
|
color = primaryTextColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = "Add a description (optional)",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Description",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
TextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { newValue -> description = newValue.take(400) },
|
||||||
label = { Text("Description (optional)") },
|
modifier =
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Modifier
|
||||||
shape = RoundedCornerShape(12.dp),
|
.fillMaxWidth()
|
||||||
minLines = 3,
|
.height(150.dp)
|
||||||
maxLines = 4
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(sectionColor),
|
||||||
|
minLines = 6,
|
||||||
|
maxLines = 8,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = primaryTextColor, fontSize = 16.sp),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "Group description",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 16.sp
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (isLoading) return@Button
|
|
||||||
errorText = null
|
|
||||||
isLoading = true
|
|
||||||
scope.launch {
|
|
||||||
val result = withContext(Dispatchers.IO) { createGroup() }
|
|
||||||
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
|
|
||||||
openGroup(result.dialogPublicKey, result.title)
|
|
||||||
} else {
|
|
||||||
errorText =
|
|
||||||
mapError(
|
|
||||||
result.status,
|
|
||||||
result.error ?: "Cannot create group"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
colors =
|
||||||
enabled = title.trim().isNotEmpty() && !isLoading
|
TextFieldDefaults.colors(
|
||||||
) {
|
focusedTextColor = primaryTextColor,
|
||||||
if (isLoading) {
|
unfocusedTextColor = primaryTextColor,
|
||||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
focusedContainerColor = sectionColor,
|
||||||
} else {
|
unfocusedContainerColor = sectionColor,
|
||||||
Text("Create Group")
|
disabledContainerColor = sectionColor,
|
||||||
}
|
focusedIndicatorColor = Color.Transparent,
|
||||||
}
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
} else {
|
focusedPlaceholderColor = secondaryTextColor,
|
||||||
OutlinedTextField(
|
unfocusedPlaceholderColor = secondaryTextColor,
|
||||||
value = inviteString,
|
cursorColor = accentColor
|
||||||
onValueChange = { inviteString = it },
|
|
||||||
label = { Text("Invite string") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
minLines = 3,
|
|
||||||
maxLines = 6,
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (isLoading) return@Button
|
|
||||||
errorText = null
|
|
||||||
isLoading = true
|
|
||||||
scope.launch {
|
|
||||||
val result = withContext(Dispatchers.IO) { joinGroup() }
|
|
||||||
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
|
|
||||||
openGroup(result.dialogPublicKey, result.title)
|
|
||||||
} else {
|
|
||||||
errorText =
|
|
||||||
mapError(
|
|
||||||
result.status,
|
|
||||||
result.error ?: "Cannot join group"
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
isLoading = false
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
Text(
|
||||||
},
|
text = "Description is optional. You can change it later in group info.",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
color = secondaryTextColor,
|
||||||
enabled = inviteString.trim().isNotEmpty() && !isLoading
|
fontSize = 13.sp,
|
||||||
) {
|
textAlign = TextAlign.Start
|
||||||
if (isLoading) {
|
)
|
||||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Text("Join Group")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!errorText.isNullOrBlank()) {
|
if (!errorText.isNullOrBlank()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = errorText ?: "",
|
text = errorText.orEmpty(),
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
FloatingActionButton(
|
||||||
Row(modifier = Modifier.fillMaxWidth()) {
|
onClick = {
|
||||||
Text(
|
if (step == GroupSetupStep.DETAILS) {
|
||||||
text =
|
if (canGoNext) {
|
||||||
if (selectedTab == 0) {
|
errorText = null
|
||||||
"Creates a new private group and joins it automatically."
|
step = GroupSetupStep.DESCRIPTION
|
||||||
|
}
|
||||||
|
return@FloatingActionButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canCreate) return@FloatingActionButton
|
||||||
|
|
||||||
|
errorText = null
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) { createGroup() }
|
||||||
|
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
persistLocalGroupAvatar(
|
||||||
|
context = context,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
groupDialogPublicKey = result.dialogPublicKey,
|
||||||
|
avatarUriString = selectedAvatarUri
|
||||||
|
)
|
||||||
|
}
|
||||||
|
openGroup(
|
||||||
|
dialogPublicKey = result.dialogPublicKey,
|
||||||
|
groupTitle = result.title.ifBlank { title.trim() }
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
"Paste a full invite string that starts with #group:."
|
errorText = mapError(result.status, result.error ?: "Cannot create group")
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = Color.White,
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = run {
|
||||||
|
// Берём максимум из всех позиций — при переключении keyboard↔emoji
|
||||||
|
// одна уходит вниз, другая уже на месте, FAB не прыгает.
|
||||||
|
val keyboardBottom = if (imeBottomDp > 0.dp) imeBottomDp + 14.dp else 0.dp
|
||||||
|
val emojiBottom = if (coordinator.isEmojiBoxVisible && coordinator.emojiHeight > 0.dp) coordinator.emojiHeight + 14.dp else 0.dp
|
||||||
|
val fabBottom = maxOf(keyboardBottom, emojiBottom, 18.dp)
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(end = 16.dp, bottom = fabBottom)
|
||||||
|
.size(58.dp)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = "Continue"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfilePhotoPicker(
|
||||||
|
isVisible = showPhotoPicker,
|
||||||
|
onDismiss = { showPhotoPicker = false },
|
||||||
|
onPhotoSelected = { uri ->
|
||||||
|
showPhotoPicker = false
|
||||||
|
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
|
||||||
|
cropLauncher.launch(cropIntent)
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistLocalGroupAvatar(
|
||||||
|
context: android.content.Context,
|
||||||
|
avatarRepository: AvatarRepository?,
|
||||||
|
groupDialogPublicKey: String,
|
||||||
|
avatarUriString: String?
|
||||||
|
) {
|
||||||
|
val repository = avatarRepository ?: return
|
||||||
|
val safeUri = avatarUriString?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
|
||||||
|
val uri = runCatching { Uri.parse(safeUri) }.getOrNull() ?: return
|
||||||
|
val rawBytes =
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { stream -> stream.readBytes() }
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
|
||||||
|
if (rawBytes.isEmpty()) return
|
||||||
|
|
||||||
|
val preparedBase64 = AvatarFileManager.imagePrepareForNetworkTransfer(context, rawBytes)
|
||||||
|
if (preparedBase64.isBlank()) return
|
||||||
|
|
||||||
|
repository.saveAvatar(
|
||||||
|
fromPublicKey = groupDialogPublicKey,
|
||||||
|
base64Image = "data:image/png;base64,$preparedBase64"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeEmojiCodeToUnicode(value: String): String {
|
||||||
|
val match = Regex("^:emoji_([a-fA-F0-9_-]+):$").matchEntire(value) ?: return value
|
||||||
|
val codePoints =
|
||||||
|
match.groupValues[1]
|
||||||
|
.split("-")
|
||||||
|
.mapNotNull { code -> code.toIntOrNull(16) }
|
||||||
|
|
||||||
|
if (codePoints.isEmpty()) return value
|
||||||
|
return String(codePoints.toIntArray(), 0, codePoints.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortPublicKey(publicKey: String): String {
|
||||||
|
val normalized = publicKey.trim()
|
||||||
|
return if (normalized.length <= 12) normalized else "${normalized.take(6)}...${normalized.takeLast(4)}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ internal fun CameraGridItem(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
|
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||||
|
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
|
||||||
|
|
||||||
val iconScale = remember { Animatable(0f) }
|
val iconScale = remember { Animatable(0f) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -145,6 +147,21 @@ internal fun CameraGridItem(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
||||||
|
onDispose {
|
||||||
|
val provider = cameraProvider
|
||||||
|
val preview = previewUseCase
|
||||||
|
if (provider != null && preview != null) {
|
||||||
|
try {
|
||||||
|
provider.unbind(preview)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewUseCase = null
|
||||||
|
cameraProvider = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
@@ -164,17 +181,24 @@ internal fun CameraGridItem(
|
|||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
val cameraProvider = cameraProviderFuture.get()
|
val provider = cameraProviderFuture.get()
|
||||||
|
cameraProvider = provider
|
||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
}
|
}
|
||||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
cameraProvider.unbindAll()
|
previewUseCase?.let { existing ->
|
||||||
cameraProvider.bindToLifecycle(
|
try {
|
||||||
|
provider.unbind(existing)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
cameraSelector,
|
cameraSelector,
|
||||||
preview
|
preview
|
||||||
)
|
)
|
||||||
|
previewUseCase = preview
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Camera init failed
|
// Camera init failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,6 +440,17 @@ fun ChatAttachAlert(
|
|||||||
} else {
|
} else {
|
||||||
requestPermissions()
|
requestPermissions()
|
||||||
}
|
}
|
||||||
|
} else if (shouldShow || isClosing) {
|
||||||
|
// Parent hidden externally (e.g. app background): force-close immediately.
|
||||||
|
// This guarantees camera preview does not stay active across app reopen.
|
||||||
|
pendingCaptionFocus = false
|
||||||
|
captionInputActive = false
|
||||||
|
showEmojiPicker = false
|
||||||
|
coordinator.isEmojiVisible = false
|
||||||
|
coordinator.isEmojiBoxVisible = false
|
||||||
|
isClosing = false
|
||||||
|
shouldShow = false
|
||||||
|
showAlbumMenu = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1433,7 +1433,6 @@ fun FileAttachment(
|
|||||||
messageStatus: MessageStatus = MessageStatus.READ
|
messageStatus: MessageStatus = MessageStatus.READ
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||||
var downloadProgress by remember { mutableStateOf(0f) }
|
var downloadProgress by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
@@ -1465,7 +1464,30 @@ fun FileAttachment(
|
|||||||
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
||||||
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
|
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
|
||||||
|
|
||||||
|
// 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
|
||||||
|
val managerState by com.rosetta.messenger.network.FileDownloadManager
|
||||||
|
.progressOf(attachment.id)
|
||||||
|
.collectAsState(initial = null)
|
||||||
|
|
||||||
|
// Синхронизируем локальный UI с глобальным менеджером
|
||||||
|
LaunchedEffect(managerState) {
|
||||||
|
val state = managerState ?: return@LaunchedEffect
|
||||||
|
downloadProgress = state.progress
|
||||||
|
downloadStatus = when (state.status) {
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
|
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||||
|
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||||
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
downloadStatus = if (isDownloadTag(preview)) {
|
downloadStatus = if (isDownloadTag(preview)) {
|
||||||
// Проверяем, был ли файл уже скачан ранее
|
// Проверяем, был ли файл уже скачан ранее
|
||||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
@@ -1507,76 +1529,20 @@ fun FileAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||||
val download: () -> Unit = {
|
val download: () -> Unit = {
|
||||||
if (downloadTag.isNotEmpty()) {
|
if (downloadTag.isNotEmpty()) {
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
downloadProgress = 0f
|
||||||
// Streaming: скачиваем во temp file, не в память
|
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||||
val success =
|
context = context,
|
||||||
if (isGroupStoredKey(chachaKey)) {
|
attachmentId = attachment.id,
|
||||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
downloadTag = downloadTag,
|
||||||
downloadProgress = 0.5f
|
chachaKey = chachaKey,
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
privateKey = privateKey,
|
||||||
|
fileName = fileName,
|
||||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
savedFile = savedFile
|
||||||
if (groupPassword.isNullOrBlank()) {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
|
||||||
val bytes = decrypted?.let { decodeBase64Payload(it) }
|
|
||||||
if (bytes != null) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
savedFile.parentFile?.mkdirs()
|
|
||||||
savedFile.writeBytes(bytes)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Streaming: скачиваем во temp file, не в память
|
|
||||||
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
|
|
||||||
downloadProgress = 0.5f
|
|
||||||
|
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
|
||||||
|
|
||||||
val decryptedKeyAndNonce =
|
|
||||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
|
||||||
downloadProgress = 0.6f
|
|
||||||
|
|
||||||
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
|
||||||
// Пиковое потребление памяти ~128KB вместо ~200MB
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
MessageCrypto.decryptAttachmentFileStreaming(
|
|
||||||
tempFile,
|
|
||||||
decryptedKeyAndNonce,
|
|
||||||
savedFile
|
|
||||||
)
|
)
|
||||||
} finally {
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadProgress = 0.95f
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
downloadProgress = 1f
|
|
||||||
downloadStatus = DownloadStatus.DOWNLOADED
|
|
||||||
} else {
|
|
||||||
downloadStatus = DownloadStatus.ERROR
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
downloadStatus = DownloadStatus.ERROR
|
|
||||||
} catch (_: OutOfMemoryError) {
|
|
||||||
System.gc()
|
|
||||||
downloadStatus = DownloadStatus.ERROR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1623,7 +1589,9 @@ fun FileAttachment(
|
|||||||
) {
|
) {
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||||
|
// Determinate progress like Telegram
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
|
progress = downloadProgress.coerceIn(0f, 1f),
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
@@ -1693,10 +1661,14 @@ fun FileAttachment(
|
|||||||
|
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING -> {
|
DownloadStatus.DOWNLOADING -> {
|
||||||
AnimatedDotsText(
|
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||||
baseText = "Downloading",
|
// CDN download maps to progress 0..0.8
|
||||||
color = statusColor,
|
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||||
fontSize = 12.sp
|
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||||
|
Text(
|
||||||
|
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = statusColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DownloadStatus.DECRYPTING -> {
|
DownloadStatus.DECRYPTING -> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Block
|
import androidx.compose.material.icons.filled.Block
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Groups
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -592,11 +593,19 @@ fun MessageBubble(
|
|||||||
.IMAGE
|
.IMAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isStandaloneGroupInvite =
|
||||||
|
message.attachments.isEmpty() &&
|
||||||
|
message.replyData == null &&
|
||||||
|
message.forwardedMessages.isEmpty() &&
|
||||||
|
message.text.isNotBlank() &&
|
||||||
|
isGroupInviteCode(message.text)
|
||||||
|
|
||||||
// Для сообщений только с фото - минимальный padding и тонкий border
|
// Для сообщений только с фото - минимальный padding и тонкий border
|
||||||
// Для фото + caption - padding только внизу для текста
|
// Для фото + caption - padding только внизу для текста
|
||||||
val bubblePadding =
|
val bubblePadding =
|
||||||
when {
|
when {
|
||||||
isSafeSystemMessage -> PaddingValues(0.dp)
|
isSafeSystemMessage -> PaddingValues(0.dp)
|
||||||
|
isStandaloneGroupInvite -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||||
hasOnlyMedia -> PaddingValues(0.dp)
|
hasOnlyMedia -> PaddingValues(0.dp)
|
||||||
hasImageWithCaption -> PaddingValues(0.dp)
|
hasImageWithCaption -> PaddingValues(0.dp)
|
||||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||||
@@ -676,6 +685,8 @@ fun MessageBubble(
|
|||||||
val bubbleWidthModifier =
|
val bubbleWidthModifier =
|
||||||
if (isSafeSystemMessage) {
|
if (isSafeSystemMessage) {
|
||||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||||
|
} else if (isStandaloneGroupInvite) {
|
||||||
|
Modifier.widthIn(min = 180.dp, max = 260.dp)
|
||||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||||
Modifier.width(
|
Modifier.width(
|
||||||
photoWidth
|
photoWidth
|
||||||
@@ -703,7 +714,11 @@ fun MessageBubble(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick
|
onLongClick = onLongClick
|
||||||
)
|
)
|
||||||
.clip(bubbleShape)
|
.then(
|
||||||
|
if (false) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.clip(bubbleShape)
|
||||||
.then(
|
.then(
|
||||||
if (hasOnlyMedia) {
|
if (hasOnlyMedia) {
|
||||||
Modifier.border(
|
Modifier.border(
|
||||||
@@ -743,6 +758,8 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(bubblePadding)
|
.padding(bubblePadding)
|
||||||
|
}
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
if (isSafeSystemMessage) {
|
if (isSafeSystemMessage) {
|
||||||
SafeSystemMessageCard(
|
SafeSystemMessageCard(
|
||||||
@@ -1045,35 +1062,12 @@ fun MessageBubble(
|
|||||||
accountPublicKey = currentUserPublicKey,
|
accountPublicKey = currentUserPublicKey,
|
||||||
accountPrivateKey = privateKey,
|
accountPrivateKey = privateKey,
|
||||||
actionsEnabled = !isSelectionMode,
|
actionsEnabled = !isSelectionMode,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
messageStatus = displayStatus,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete,
|
||||||
onOpenGroup = onGroupInviteOpen
|
onOpenGroup = onGroupInviteOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = timeFormat.format(message.timestamp),
|
|
||||||
color = timeColor,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
|
||||||
)
|
|
||||||
if (message.isOutgoing) {
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
AnimatedMessageStatus(
|
|
||||||
status = displayStatus,
|
|
||||||
timeColor = statusColor,
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
isOutgoing = message.isOutgoing,
|
|
||||||
timestamp = message.timestamp.time,
|
|
||||||
onRetry = onRetry,
|
|
||||||
onDelete = onDelete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Telegram-style: текст + время с автоматическим
|
// Telegram-style: текст + время с автоматическим
|
||||||
// переносом
|
// переносом
|
||||||
@@ -1275,6 +1269,10 @@ private fun GroupInviteInlineCard(
|
|||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
accountPrivateKey: String,
|
accountPrivateKey: String,
|
||||||
actionsEnabled: Boolean,
|
actionsEnabled: Boolean,
|
||||||
|
timestamp: Date,
|
||||||
|
messageStatus: MessageStatus,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
onOpenGroup: (SearchUser) -> Unit
|
onOpenGroup: (SearchUser) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -1284,9 +1282,10 @@ private fun GroupInviteInlineCard(
|
|||||||
val normalizedInvite = remember(inviteText) { inviteText.trim() }
|
val normalizedInvite = remember(inviteText) { inviteText.trim() }
|
||||||
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
|
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
|
||||||
|
|
||||||
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
|
val cachedInfo = remember(normalizedInvite) { parsedInvite?.let { groupRepository.getCachedInviteInfo(it.groupId) } }
|
||||||
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
|
var status by remember(normalizedInvite) { mutableStateOf(cachedInfo?.status ?: GroupStatus.NOT_JOINED) }
|
||||||
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
|
var membersCount by remember(normalizedInvite) { mutableStateOf(cachedInfo?.membersCount ?: 0) }
|
||||||
|
var statusLoading by remember(normalizedInvite) { mutableStateOf(cachedInfo == null) }
|
||||||
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
|
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(normalizedInvite, accountPublicKey) {
|
LaunchedEffect(normalizedInvite, accountPublicKey) {
|
||||||
@@ -1297,7 +1296,9 @@ private fun GroupInviteInlineCard(
|
|||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cachedInfo == null) {
|
||||||
statusLoading = true
|
statusLoading = true
|
||||||
|
}
|
||||||
|
|
||||||
val localGroupExists =
|
val localGroupExists =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -1314,12 +1315,13 @@ private fun GroupInviteInlineCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
membersCount = inviteInfo?.membersCount ?: 0
|
membersCount = inviteInfo?.membersCount ?: 0
|
||||||
status =
|
val newStatus = when {
|
||||||
when {
|
|
||||||
localGroupExists -> GroupStatus.JOINED
|
localGroupExists -> GroupStatus.JOINED
|
||||||
inviteInfo != null -> inviteInfo.status
|
inviteInfo != null -> inviteInfo.status
|
||||||
else -> GroupStatus.NOT_JOINED
|
else -> GroupStatus.NOT_JOINED
|
||||||
}
|
}
|
||||||
|
status = newStatus
|
||||||
|
groupRepository.cacheInviteInfo(parsedInvite.groupId, newStatus, membersCount)
|
||||||
|
|
||||||
statusLoading = false
|
statusLoading = false
|
||||||
}
|
}
|
||||||
@@ -1350,30 +1352,16 @@ private fun GroupInviteInlineCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardBackground =
|
|
||||||
if (isOutgoing) {
|
|
||||||
Color.White.copy(alpha = 0.16f)
|
|
||||||
} else if (isDarkTheme) {
|
|
||||||
Color.White.copy(alpha = 0.06f)
|
|
||||||
} else {
|
|
||||||
Color.Black.copy(alpha = 0.03f)
|
|
||||||
}
|
|
||||||
val cardBorder =
|
|
||||||
if (isOutgoing) {
|
|
||||||
Color.White.copy(alpha = 0.22f)
|
|
||||||
} else if (isDarkTheme) {
|
|
||||||
Color.White.copy(alpha = 0.12f)
|
|
||||||
} else {
|
|
||||||
Color.Black.copy(alpha = 0.08f)
|
|
||||||
}
|
|
||||||
val titleColor =
|
|
||||||
if (isOutgoing) Color.White
|
|
||||||
else if (isDarkTheme) Color.White
|
|
||||||
else Color(0xFF1A1A1A)
|
|
||||||
val subtitleColor =
|
val subtitleColor =
|
||||||
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
||||||
else if (isDarkTheme) Color(0xFFA9AFBA)
|
else if (isDarkTheme) Color(0xFFA9AFBA)
|
||||||
else Color(0xFF70757F)
|
else Color(0xFF70757F)
|
||||||
|
val timeColor =
|
||||||
|
if (isOutgoing) Color.White.copy(alpha = 0.74f)
|
||||||
|
else if (isDarkTheme) Color(0xFF8E8E93)
|
||||||
|
else Color(0xFF666666)
|
||||||
|
val statusColor = if (isOutgoing) Color.White else timeColor
|
||||||
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
val accentColor =
|
val accentColor =
|
||||||
when (status) {
|
when (status) {
|
||||||
@@ -1441,6 +1429,7 @@ private fun GroupInviteInlineCard(
|
|||||||
|
|
||||||
if (joinResult.success) {
|
if (joinResult.success) {
|
||||||
status = GroupStatus.JOINED
|
status = GroupStatus.JOINED
|
||||||
|
groupRepository.cacheInviteInfo(parsedInvite.groupId, GroupStatus.JOINED, membersCount)
|
||||||
openParsedGroup()
|
openParsedGroup()
|
||||||
} else {
|
} else {
|
||||||
status = joinResult.status
|
status = joinResult.status
|
||||||
@@ -1455,28 +1444,27 @@ private fun GroupInviteInlineCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Column {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// Icon + Title row
|
||||||
color = cardBackground,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
// Group icon circle
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.size(34.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
.background(
|
||||||
|
if (isOutgoing) Color.White.copy(alpha = 0.15f)
|
||||||
|
else accentColor.copy(alpha = 0.12f)
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Link,
|
imageVector = Icons.Default.Groups,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = accentColor,
|
tint = if (isOutgoing) Color.White else accentColor,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1485,63 +1473,93 @@ private fun GroupInviteInlineCard(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
color = titleColor,
|
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color(0xFF1A1A1A),
|
||||||
fontSize = 14.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
color = subtitleColor,
|
color = subtitleColor,
|
||||||
fontSize = 11.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Action button (full width)
|
||||||
Surface(
|
Surface(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(
|
||||||
enabled = actionEnabled,
|
enabled = actionEnabled,
|
||||||
onClick = ::handleAction
|
onClick = ::handleAction
|
||||||
),
|
),
|
||||||
color =
|
color = if (isOutgoing) Color.White.copy(alpha = 0.18f)
|
||||||
if (isOutgoing) {
|
else accentColor.copy(alpha = 0.10f),
|
||||||
Color.White.copy(alpha = 0.2f)
|
|
||||||
} else {
|
|
||||||
accentColor.copy(alpha = 0.14f)
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
modifier = Modifier.padding(vertical = 7.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
if (actionLoading || statusLoading) {
|
if (actionLoading || statusLoading) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(12.dp),
|
modifier = Modifier.size(13.dp),
|
||||||
strokeWidth = 1.8.dp,
|
strokeWidth = 1.5.dp,
|
||||||
color = accentColor
|
color = if (isOutgoing) Color.White else accentColor
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = actionIcon,
|
imageVector = actionIcon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = accentColor,
|
tint = if (isOutgoing) Color.White else accentColor,
|
||||||
modifier = Modifier.size(12.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text(
|
Text(
|
||||||
text = actionLabel,
|
text = actionLabel,
|
||||||
color = accentColor,
|
color = if (isOutgoing) Color.White else accentColor,
|
||||||
fontSize = 11.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Time + status row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = timeFormat.format(timestamp),
|
||||||
|
color = timeColor,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
if (isOutgoing) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
AnimatedMessageStatus(
|
||||||
|
status = messageStatus,
|
||||||
|
timeColor = statusColor,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = timestamp.time,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1632,6 +1650,19 @@ fun AnimatedMessageStatus(
|
|||||||
onRetry: () -> Unit = {},
|
onRetry: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {}
|
onDelete: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
// Force recomposition when SENDING message exceeds 80s timeout
|
||||||
|
var timeoutTick by remember { mutableLongStateOf(0L) }
|
||||||
|
if (status == MessageStatus.SENDING && timestamp > 0) {
|
||||||
|
val remainingMs = (timestamp + 80_000L) - System.currentTimeMillis()
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
LaunchedEffect(timestamp) {
|
||||||
|
kotlinx.coroutines.delay(remainingMs + 100)
|
||||||
|
timeoutTick++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Suppress("UNUSED_EXPRESSION") timeoutTick // read to subscribe
|
||||||
|
|
||||||
val isTimedOut =
|
val isTimedOut =
|
||||||
status == MessageStatus.SENDING &&
|
status == MessageStatus.SENDING &&
|
||||||
timestamp > 0 &&
|
timestamp > 0 &&
|
||||||
@@ -1688,18 +1719,30 @@ fun AnimatedMessageStatus(
|
|||||||
label = "statusIcon"
|
label = "statusIcon"
|
||||||
) { currentStatus ->
|
) { currentStatus ->
|
||||||
if (currentStatus == MessageStatus.ERROR) {
|
if (currentStatus == MessageStatus.ERROR) {
|
||||||
Icon(
|
// Telegram-style: red filled circle with white "!" inside
|
||||||
imageVector = TablerIcons.AlertCircle,
|
Box(
|
||||||
contentDescription = null,
|
|
||||||
tint = animatedColor,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.size(iconSize)
|
Modifier.padding(start = 4.dp)
|
||||||
|
.size(iconSize)
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
|
.background(
|
||||||
|
color = Color(0xFFE53935),
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
.clickable {
|
.clickable {
|
||||||
showErrorMenu = true
|
showErrorMenu = true
|
||||||
}
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "!",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||||
|
lineHeight = 10.sp
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (currentStatus == MessageStatus.READ) {
|
if (currentStatus == MessageStatus.READ) {
|
||||||
Box(
|
Box(
|
||||||
@@ -1746,39 +1789,76 @@ fun AnimatedMessageStatus(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
|
||||||
|
val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222)
|
||||||
|
val menuIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = MaterialTheme.colorScheme.copy(
|
||||||
|
surface = menuBgColor,
|
||||||
|
onSurface = menuTextColor
|
||||||
|
)
|
||||||
|
) {
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showErrorMenu,
|
expanded = showErrorMenu,
|
||||||
onDismissRequest = { showErrorMenu = false }
|
onDismissRequest = { showErrorMenu = false },
|
||||||
|
modifier = Modifier
|
||||||
|
.defaultMinSize(minWidth = 196.dp)
|
||||||
|
.background(menuBgColor)
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
Box(
|
||||||
text = { Text("Retry") },
|
modifier = Modifier
|
||||||
onClick = {
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = 48.dp)
|
||||||
|
.clickable {
|
||||||
showErrorMenu = false
|
showErrorMenu = false
|
||||||
onRetry()
|
onRetry()
|
||||||
},
|
}
|
||||||
leadingIcon = {
|
.padding(horizontal = 18.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = TelegramIcons.Retry,
|
painter = TelegramIcons.Retry,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp)
|
tint = menuIconColor,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(19.dp))
|
||||||
|
Text(
|
||||||
|
text = "Retry",
|
||||||
|
color = menuTextColor,
|
||||||
|
fontSize = 16.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
DropdownMenuItem(
|
Box(
|
||||||
text = { Text("Delete", color = Color(0xFFE53935)) },
|
modifier = Modifier
|
||||||
onClick = {
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = 48.dp)
|
||||||
|
.clickable {
|
||||||
showErrorMenu = false
|
showErrorMenu = false
|
||||||
onDelete()
|
onDelete()
|
||||||
},
|
}
|
||||||
leadingIcon = {
|
.padding(horizontal = 18.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = TelegramIcons.Delete,
|
painter = TelegramIcons.Delete,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color(0xFFE53935),
|
tint = Color(0xFFFF3B30),
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(19.dp))
|
||||||
|
Text(
|
||||||
|
text = "Delete",
|
||||||
|
color = Color(0xFFFF3B30),
|
||||||
|
fontSize = 16.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1668,6 +1668,8 @@ private fun CameraGridItem(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
|
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||||
|
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
|
||||||
|
|
||||||
// Bounce animation for camera icon
|
// Bounce animation for camera icon
|
||||||
val iconScale = remember { Animatable(0f) }
|
val iconScale = remember { Animatable(0f) }
|
||||||
@@ -1699,6 +1701,21 @@ private fun CameraGridItem(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner, hasCameraPermission) {
|
||||||
|
onDispose {
|
||||||
|
val provider = cameraProvider
|
||||||
|
val preview = previewUseCase
|
||||||
|
if (provider != null && preview != null) {
|
||||||
|
try {
|
||||||
|
provider.unbind(preview)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewUseCase = null
|
||||||
|
cameraProvider = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
@@ -1719,7 +1736,8 @@ private fun CameraGridItem(
|
|||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
try {
|
try {
|
||||||
val cameraProvider = cameraProviderFuture.get()
|
val provider = cameraProviderFuture.get()
|
||||||
|
cameraProvider = provider
|
||||||
|
|
||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
@@ -1728,12 +1746,18 @@ private fun CameraGridItem(
|
|||||||
// Use back camera
|
// Use back camera
|
||||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
cameraProvider.unbindAll()
|
previewUseCase?.let { existing ->
|
||||||
cameraProvider.bindToLifecycle(
|
try {
|
||||||
|
provider.unbind(existing)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
cameraSelector,
|
cameraSelector,
|
||||||
preview
|
preview
|
||||||
)
|
)
|
||||||
|
previewUseCase = preview
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Camera init failed
|
// Camera init failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fun Message.toChatMessage() = ChatMessage(
|
|||||||
status = when (deliveryStatus) {
|
status = when (deliveryStatus) {
|
||||||
DeliveryStatus.WAITING -> MessageStatus.SENDING
|
DeliveryStatus.WAITING -> MessageStatus.SENDING
|
||||||
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
|
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
|
||||||
DeliveryStatus.ERROR -> MessageStatus.SENT
|
DeliveryStatus.ERROR -> MessageStatus.ERROR
|
||||||
DeliveryStatus.READ -> MessageStatus.READ
|
DeliveryStatus.READ -> MessageStatus.READ
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.rosetta.messenger.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedDownloadIndicator(
|
||||||
|
isActive: Boolean,
|
||||||
|
color: Color = Color.White,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isActive,
|
||||||
|
enter = fadeIn(animationSpec = tween(200)) + scaleIn(
|
||||||
|
initialScale = 0.6f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
|
||||||
|
targetScale = 0.6f,
|
||||||
|
animationSpec = tween(150)
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
// Infinite rotation for the circular progress arc
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "download_rotation")
|
||||||
|
val rotation by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "download_rotation_angle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pulsing arrow bounce
|
||||||
|
val arrowBounce by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 800, easing = FastOutSlowInEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "arrow_bounce"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.size(24.dp)) {
|
||||||
|
val centerX = size.width / 2
|
||||||
|
val centerY = size.height / 2
|
||||||
|
val radius = size.width / 2 - 2.dp.toPx()
|
||||||
|
val strokeWidth = 2.dp.toPx()
|
||||||
|
|
||||||
|
// 1) Rotating arc (circular progress indicator)
|
||||||
|
rotate(degrees = rotation, pivot = Offset(centerX, centerY)) {
|
||||||
|
drawArc(
|
||||||
|
color = color,
|
||||||
|
startAngle = 0f,
|
||||||
|
sweepAngle = 120f,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
|
||||||
|
topLeft = Offset(centerX - radius, centerY - radius),
|
||||||
|
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Arrow pointing down (download symbol)
|
||||||
|
val arrowOffset = arrowBounce * 1.5.dp.toPx()
|
||||||
|
val arrowStroke = 2.dp.toPx()
|
||||||
|
val arrowTop = centerY - 4.dp.toPx() + arrowOffset
|
||||||
|
val arrowBottom = centerY + 4.dp.toPx() + arrowOffset
|
||||||
|
val arrowWing = 3.dp.toPx()
|
||||||
|
|
||||||
|
// Vertical line of arrow
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(centerX, arrowTop),
|
||||||
|
end = Offset(centerX, arrowBottom),
|
||||||
|
strokeWidth = arrowStroke,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
|
||||||
|
// Left wing of arrowhead
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(centerX - arrowWing, arrowBottom - arrowWing),
|
||||||
|
end = Offset(centerX, arrowBottom),
|
||||||
|
strokeWidth = arrowStroke,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
|
||||||
|
// Right wing of arrowhead
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(centerX + arrowWing, arrowBottom - arrowWing),
|
||||||
|
end = Offset(centerX, arrowBottom),
|
||||||
|
strokeWidth = arrowStroke,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ fun SwipeBackContainer(
|
|||||||
layer: Int = 1,
|
layer: Int = 1,
|
||||||
swipeEnabled: Boolean = true,
|
swipeEnabled: Boolean = true,
|
||||||
propagateBackgroundProgress: Boolean = true,
|
propagateBackgroundProgress: Boolean = true,
|
||||||
|
deferToChildren: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
||||||
@@ -243,7 +244,7 @@ fun SwipeBackContainer(
|
|||||||
alpha = currentAlpha
|
alpha = currentAlpha
|
||||||
}
|
}
|
||||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||||
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) {
|
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut, deferToChildren) {
|
||||||
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
|
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
|
||||||
|
|
||||||
val velocityTracker = VelocityTracker()
|
val velocityTracker = VelocityTracker()
|
||||||
@@ -268,12 +269,17 @@ fun SwipeBackContainer(
|
|||||||
var totalDragY = 0f
|
var totalDragY = 0f
|
||||||
var passedSlop = false
|
var passedSlop = false
|
||||||
|
|
||||||
// Use Initial pass to intercept BEFORE children
|
// deferToChildren=true: pre-slop uses Main pass so children
|
||||||
|
// (e.g. LazyRow) process first — if they consume, we back off.
|
||||||
|
// deferToChildren=false (default): always use Initial pass
|
||||||
|
// to intercept before children (original behavior).
|
||||||
|
// Post-claim: always Initial to block children.
|
||||||
while (true) {
|
while (true) {
|
||||||
val event =
|
val pass =
|
||||||
awaitPointerEvent(
|
if (startedSwipe || !deferToChildren)
|
||||||
PointerEventPass.Initial
|
PointerEventPass.Initial
|
||||||
)
|
else PointerEventPass.Main
|
||||||
|
val event = awaitPointerEvent(pass)
|
||||||
val change =
|
val change =
|
||||||
event.changes.firstOrNull {
|
event.changes.firstOrNull {
|
||||||
it.id == down.id
|
it.id == down.id
|
||||||
@@ -289,6 +295,9 @@ fun SwipeBackContainer(
|
|||||||
totalDragY += dragDelta.y
|
totalDragY += dragDelta.y
|
||||||
|
|
||||||
if (!passedSlop) {
|
if (!passedSlop) {
|
||||||
|
// Child (e.g. LazyRow) already consumed — let it handle
|
||||||
|
if (change.isConsumed) break
|
||||||
|
|
||||||
val totalDistance =
|
val totalDistance =
|
||||||
kotlin.math.sqrt(
|
kotlin.math.sqrt(
|
||||||
totalDragX *
|
totalDragX *
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -98,6 +100,7 @@ import compose.icons.tablericons.*
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
@@ -183,9 +186,30 @@ fun OtherProfileScreen(
|
|||||||
var showImageViewer by remember { mutableStateOf(false) }
|
var showImageViewer by remember { mutableStateOf(false) }
|
||||||
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
||||||
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
|
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = 0,
|
||||||
|
pageCount = { OtherProfileTab.entries.size }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tab click → animate pager
|
||||||
|
LaunchedEffect(selectedTab) {
|
||||||
|
val page = OtherProfileTab.entries.indexOf(selectedTab)
|
||||||
|
if (pagerState.currentPage != page) {
|
||||||
|
pagerState.animateScrollToPage(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pager swipe → update tab + control swipe-back
|
||||||
|
LaunchedEffect(pagerState) {
|
||||||
|
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||||
|
selectedTab = OtherProfileTab.entries[page]
|
||||||
|
// Swipe-back only on first tab (Media); on other tabs pager handles swipe
|
||||||
|
onSwipeBackEnabledChanged(page == 0 && !showImageViewer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(showImageViewer) {
|
LaunchedEffect(showImageViewer) {
|
||||||
onSwipeBackEnabledChanged(!showImageViewer)
|
onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
@@ -711,25 +735,29 @@ fun OtherProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// TAB CONTENT — inlined directly into LazyColumn items
|
// TAB CONTENT — HorizontalPager for swipe between tabs
|
||||||
// for true virtualization (only visible items compose)
|
// On first tab (Media) swipe-right triggers swipe-back;
|
||||||
|
// on other tabs swipe-right goes to the previous tab.
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
when (selectedTab) {
|
item(key = "tab_pager") {
|
||||||
OtherProfileTab.MEDIA -> {
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) { page ->
|
||||||
|
when (page) {
|
||||||
|
// ── MEDIA ──
|
||||||
|
0 -> {
|
||||||
if (sharedContent.mediaPhotos.isEmpty()) {
|
if (sharedContent.mediaPhotos.isEmpty()) {
|
||||||
item(key = "media_empty") {
|
|
||||||
OtherProfileEmptyState(
|
OtherProfileEmptyState(
|
||||||
animationAssetPath = "lottie/saved.json",
|
animationAssetPath = "lottie/saved.json",
|
||||||
title = "No shared media yet",
|
title = "No shared media yet",
|
||||||
subtitle = "Photos from your chat will appear here.",
|
subtitle = "Photos from your chat will appear here.",
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
items(
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
items = mediaIndexedRows,
|
mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
|
||||||
key = { (idx, _) -> "media_row_$idx" }
|
|
||||||
) { (rowIdx, rowPhotos) ->
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||||
@@ -737,11 +765,9 @@ fun OtherProfileScreen(
|
|||||||
rowPhotos.forEachIndexed { colIdx, media ->
|
rowPhotos.forEachIndexed { colIdx, media ->
|
||||||
val globalIndex = rowIdx * mediaColumns + colIdx
|
val globalIndex = rowIdx * mediaColumns + colIdx
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
val cachedBitmap = mediaBitmapStates[media.key]
|
val cachedBitmap = mediaBitmapStates[media.key]
|
||||||
?: SharedMediaBitmapCache.get(media.key)
|
?: SharedMediaBitmapCache.get(media.key)
|
||||||
|
|
||||||
// Only launch decode for items not yet cached
|
|
||||||
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
|
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
|
||||||
LaunchedEffect(media.key) {
|
LaunchedEffect(media.key) {
|
||||||
mediaDecodeSemaphore.withPermit {
|
mediaDecodeSemaphore.withPermit {
|
||||||
@@ -762,7 +788,6 @@ fun OtherProfileScreen(
|
|||||||
val model = remember(media.localUri, media.blob) {
|
val model = remember(media.localUri, media.blob) {
|
||||||
resolveSharedMediaModel(media.localUri, media.blob)
|
resolveSharedMediaModel(media.localUri, media.blob)
|
||||||
}
|
}
|
||||||
// Decode blurred preview from base64 (small image ~4-16px)
|
|
||||||
val previewBitmap = remember(media.preview) {
|
val previewBitmap = remember(media.preview) {
|
||||||
if (media.preview.isNotBlank()) {
|
if (media.preview.isNotBlank()) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -772,7 +797,6 @@ fun OtherProfileScreen(
|
|||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
val isLoaded = resolvedBitmap != null || model != null
|
val isLoaded = resolvedBitmap != null || model != null
|
||||||
// Animate alpha for smooth fade-in
|
|
||||||
val imageAlpha by animateFloatAsState(
|
val imageAlpha by animateFloatAsState(
|
||||||
targetValue = if (isLoaded) 1f else 0f,
|
targetValue = if (isLoaded) 1f else 0f,
|
||||||
animationSpec = tween(300),
|
animationSpec = tween(300),
|
||||||
@@ -791,7 +815,6 @@ fun OtherProfileScreen(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Blurred preview placeholder (always shown initially)
|
|
||||||
if (previewBitmap != null) {
|
if (previewBitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = previewBitmap.asImageBitmap(),
|
bitmap = previewBitmap.asImageBitmap(),
|
||||||
@@ -800,7 +823,6 @@ fun OtherProfileScreen(
|
|||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Fallback shimmer if no preview
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -810,7 +832,6 @@ fun OtherProfileScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Full quality image fades in on top
|
|
||||||
if (resolvedBitmap != null) {
|
if (resolvedBitmap != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = resolvedBitmap.asImageBitmap(),
|
bitmap = resolvedBitmap.asImageBitmap(),
|
||||||
@@ -828,7 +849,6 @@ fun OtherProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fill remaining cells in last incomplete row
|
|
||||||
repeat(mediaColumns - rowPhotos.size) {
|
repeat(mediaColumns - rowPhotos.size) {
|
||||||
Spacer(modifier = Modifier.size(mediaCellSize))
|
Spacer(modifier = Modifier.size(mediaCellSize))
|
||||||
}
|
}
|
||||||
@@ -836,22 +856,23 @@ fun OtherProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OtherProfileTab.FILES -> {
|
}
|
||||||
|
// ── FILES ──
|
||||||
|
1 -> {
|
||||||
if (sharedContent.files.isEmpty()) {
|
if (sharedContent.files.isEmpty()) {
|
||||||
item(key = "files_empty") {
|
|
||||||
OtherProfileEmptyState(
|
OtherProfileEmptyState(
|
||||||
animationAssetPath = "lottie/folder.json",
|
animationAssetPath = "lottie/folder.json",
|
||||||
title = "No shared files",
|
title = "No shared files",
|
||||||
subtitle = "Documents from this chat will appear here.",
|
subtitle = "Documents from this chat will appear here.",
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
|
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
||||||
|
|
||||||
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file ->
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
sharedContent.files.forEachIndexed { index, file ->
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -888,21 +909,22 @@ fun OtherProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OtherProfileTab.LINKS -> {
|
}
|
||||||
|
// ── LINKS ──
|
||||||
|
2 -> {
|
||||||
if (sharedContent.links.isEmpty()) {
|
if (sharedContent.links.isEmpty()) {
|
||||||
item(key = "links_empty") {
|
|
||||||
OtherProfileEmptyState(
|
OtherProfileEmptyState(
|
||||||
animationAssetPath = "lottie/earth.json",
|
animationAssetPath = "lottie/earth.json",
|
||||||
title = "No shared links",
|
title = "No shared links",
|
||||||
subtitle = "Links from your messages will appear here.",
|
subtitle = "Links from your messages will appear here.",
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
||||||
|
|
||||||
itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link ->
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
sharedContent.links.forEachIndexed { index, link ->
|
||||||
Column {
|
Column {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -928,6 +950,9 @@ fun OtherProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(key = "bottom_spacer") {
|
item(key = "bottom_spacer") {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
@@ -1402,11 +1427,24 @@ private fun openUriInExternalApp(context: android.content.Context, uri: Uri, mim
|
|||||||
|
|
||||||
private fun openSharedFile(context: android.content.Context, file: SharedFileItem): Boolean {
|
private fun openSharedFile(context: android.content.Context, file: SharedFileItem): Boolean {
|
||||||
val mimeType = inferMimeType(file.fileName)
|
val mimeType = inferMimeType(file.fileName)
|
||||||
|
|
||||||
|
// 1. Попробовать localUri
|
||||||
if (file.localUri.isNotBlank()) {
|
if (file.localUri.isNotBlank()) {
|
||||||
val local = runCatching { Uri.parse(file.localUri) }.getOrNull()
|
val local = runCatching { Uri.parse(file.localUri) }.getOrNull()
|
||||||
if (local != null && openUriInExternalApp(context, local, mimeType)) return true
|
if (local != null && openUriInExternalApp(context, local, mimeType)) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Проверить rosetta_downloads (файлы скачанные через CDN в чате)
|
||||||
|
val downloadsDir = File(context.filesDir, "rosetta_downloads")
|
||||||
|
val downloadedFile = File(downloadsDir, file.fileName)
|
||||||
|
if (downloadedFile.exists() && downloadedFile.length() > 0) {
|
||||||
|
val uri = runCatching {
|
||||||
|
FileProvider.getUriForFile(context, "${context.packageName}.provider", downloadedFile)
|
||||||
|
}.getOrNull()
|
||||||
|
if (uri != null && openUriInExternalApp(context, uri, mimeType)) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Попробовать blob (inline-файлы)
|
||||||
val bytes = decodeBlobPayload(file.blob) ?: return false
|
val bytes = decodeBlobPayload(file.blob) ?: return false
|
||||||
val extension = file.fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
|
val extension = file.fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
|
||||||
val suffix = if (extension.isBlank()) ".bin" else ".$extension"
|
val suffix = if (extension.isBlank()) ".bin" else ".$extension"
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import androidx.compose.animation.core.Animatable
|
|||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -18,7 +21,6 @@ import androidx.compose.material3.*
|
|||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -37,7 +39,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
@@ -64,8 +68,10 @@ private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
|
|||||||
fun ThemeScreen(
|
fun ThemeScreen(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
currentThemeMode: String,
|
currentThemeMode: String,
|
||||||
|
currentWallpaperId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onThemeModeChange: (String) -> Unit
|
onThemeModeChange: (String) -> Unit,
|
||||||
|
onWallpaperChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
@@ -78,7 +84,6 @@ fun ThemeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -95,11 +100,16 @@ fun ThemeScreen(
|
|||||||
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
var wallpaperId by remember { mutableStateOf(currentWallpaperId) }
|
||||||
|
|
||||||
LaunchedEffect(currentThemeMode) {
|
LaunchedEffect(currentThemeMode) {
|
||||||
themeMode = currentThemeMode
|
themeMode = currentThemeMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(currentWallpaperId) {
|
||||||
|
wallpaperId = currentWallpaperId
|
||||||
|
}
|
||||||
|
|
||||||
fun resolveThemeIsDark(mode: String): Boolean =
|
fun resolveThemeIsDark(mode: String): Boolean =
|
||||||
when (mode) {
|
when (mode) {
|
||||||
"dark" -> true
|
"dark" -> true
|
||||||
@@ -214,7 +224,7 @@ fun ThemeScreen(
|
|||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// CHAT PREVIEW - Message bubbles like in real chat
|
// CHAT PREVIEW - Message bubbles like in real chat
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
ChatPreview(isDarkTheme = isDarkTheme)
|
ChatPreview(isDarkTheme = isDarkTheme, wallpaperId = wallpaperId)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -266,7 +276,27 @@ fun ThemeScreen(
|
|||||||
secondaryTextColor = secondaryTextColor
|
secondaryTextColor = secondaryTextColor
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
TelegramSectionHeader("Chat Wallpaper", secondaryTextColor)
|
||||||
|
|
||||||
|
WallpaperSelectorRow(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
selectedWallpaperId = wallpaperId,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onWallpaperSelected = { selectedId ->
|
||||||
|
if (selectedId != wallpaperId) {
|
||||||
|
wallpaperId = selectedId
|
||||||
|
onWallpaperChange(selectedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TelegramInfoText(
|
||||||
|
text = "Selected wallpaper is used for chat backgrounds.",
|
||||||
|
secondaryTextColor = secondaryTextColor
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,16 +450,111 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WallpaperSelectorRow(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
selectedWallpaperId: String,
|
||||||
|
textColor: Color,
|
||||||
|
secondaryTextColor: Color,
|
||||||
|
onWallpaperSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item(key = "none") {
|
||||||
|
WallpaperSelectorItem(
|
||||||
|
title = "No wallpaper",
|
||||||
|
wallpaperResId = null,
|
||||||
|
isSelected = selectedWallpaperId.isBlank(),
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onClick = { onWallpaperSelected("") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
|
||||||
|
WallpaperSelectorItem(
|
||||||
|
title = wallpaper.name,
|
||||||
|
wallpaperResId = wallpaper.drawableRes,
|
||||||
|
isSelected = wallpaper.id == selectedWallpaperId,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onClick = { onWallpaperSelected(wallpaper.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WallpaperSelectorItem(
|
||||||
|
title: String,
|
||||||
|
wallpaperResId: Int?,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
textColor: Color,
|
||||||
|
secondaryTextColor: Color,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val borderColor = if (isSelected) Color(0xFF007AFF) else secondaryTextColor.copy(alpha = 0.35f)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.width(118.dp).clickable(onClick = onClick),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(76.dp),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor)
|
||||||
|
) {
|
||||||
|
if (wallpaperResId != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = wallpaperResId),
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (isSelected) textColor else secondaryTextColor,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// 💬 CHAT PREVIEW - Real message bubbles preview
|
// 💬 CHAT PREVIEW - Real message bubbles preview
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatPreview(isDarkTheme: Boolean) {
|
private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
|
||||||
|
val wallpaperResId = remember(wallpaperId) { ThemeWallpapers.drawableResOrNull(wallpaperId) }
|
||||||
|
val hasWallpaper = wallpaperResId != null
|
||||||
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
|
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
|
||||||
// Message colors matching real ChatDetailScreen
|
// Message colors matching real ChatDetailScreen
|
||||||
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
|
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
|
||||||
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
val otherBubbleColor =
|
||||||
|
if (hasWallpaper) {
|
||||||
|
if (isDarkTheme) Color(0xFF2C2E33) else Color.White
|
||||||
|
} else {
|
||||||
|
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||||
|
}
|
||||||
val myTextColor = Color.White // White text on blue bubble
|
val myTextColor = Color.White // White text on blue bubble
|
||||||
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
|
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
|
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
|
||||||
@@ -444,10 +569,18 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
|||||||
color = chatBgColor,
|
color = chatBgColor,
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (wallpaperResId != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = wallpaperResId),
|
||||||
|
contentDescription = "Chat wallpaper preview",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
// Incoming message
|
// Incoming message
|
||||||
@@ -497,6 +630,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.rosetta.messenger.ui.settings
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.rosetta.messenger.R
|
||||||
|
|
||||||
|
data class ThemeWallpaper(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
@DrawableRes val drawableRes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
object ThemeWallpapers {
|
||||||
|
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
|
||||||
|
val all: List<ThemeWallpaper> =
|
||||||
|
listOf(
|
||||||
|
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3),
|
||||||
|
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4),
|
||||||
|
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5),
|
||||||
|
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6),
|
||||||
|
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7),
|
||||||
|
ThemeWallpaper(id = "back_8", name = "Wallpaper 6", drawableRes = R.drawable.wallpaper_back_8),
|
||||||
|
ThemeWallpaper(id = "back_9", name = "Wallpaper 7", drawableRes = R.drawable.wallpaper_back_9),
|
||||||
|
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10),
|
||||||
|
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11),
|
||||||
|
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1),
|
||||||
|
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
||||||
|
}
|
||||||
BIN
app/src/main/res/drawable-hdpi/msg_download.png
Normal file
|
After Width: | Height: | Size: 276 B |
BIN
app/src/main/res/drawable-hdpi/search_files_filled.png
Normal file
|
After Width: | Height: | Size: 207 B |
BIN
app/src/main/res/drawable-hdpi/search_links_filled.png
Normal file
|
After Width: | Height: | Size: 413 B |
BIN
app/src/main/res/drawable-hdpi/search_media_filled.png
Normal file
|
After Width: | Height: | Size: 237 B |
BIN
app/src/main/res/drawable-hdpi/search_music_filled.png
Normal file
|
After Width: | Height: | Size: 310 B |
BIN
app/src/main/res/drawable-mdpi/msg_download.png
Normal file
|
After Width: | Height: | Size: 161 B |
BIN
app/src/main/res/drawable-mdpi/search_files_filled.png
Normal file
|
After Width: | Height: | Size: 141 B |
BIN
app/src/main/res/drawable-mdpi/search_links_filled.png
Normal file
|
After Width: | Height: | Size: 319 B |
BIN
app/src/main/res/drawable-mdpi/search_media_filled.png
Normal file
|
After Width: | Height: | Size: 152 B |
BIN
app/src/main/res/drawable-mdpi/search_music_filled.png
Normal file
|
After Width: | Height: | Size: 201 B |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_10.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_11.png
Normal file
|
After Width: | Height: | Size: 879 KiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_3.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_4.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_5.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_6.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_7.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_8.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/src/main/res/drawable-nodpi/wallpaper_back_9.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
app/src/main/res/drawable-xhdpi/msg_download.png
Normal file
|
After Width: | Height: | Size: 258 B |
BIN
app/src/main/res/drawable-xhdpi/search_files_filled.png
Normal file
|
After Width: | Height: | Size: 228 B |
BIN
app/src/main/res/drawable-xhdpi/search_links_filled.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
app/src/main/res/drawable-xhdpi/search_media_filled.png
Normal file
|
After Width: | Height: | Size: 257 B |
BIN
app/src/main/res/drawable-xhdpi/search_music_filled.png
Normal file
|
After Width: | Height: | Size: 348 B |
BIN
app/src/main/res/drawable-xxhdpi/msg_download.png
Normal file
|
After Width: | Height: | Size: 267 B |
BIN
app/src/main/res/drawable-xxhdpi/search_files_filled.png
Normal file
|
After Width: | Height: | Size: 277 B |
BIN
app/src/main/res/drawable-xxhdpi/search_links_filled.png
Normal file
|
After Width: | Height: | Size: 636 B |
BIN
app/src/main/res/drawable-xxhdpi/search_media_filled.png
Normal file
|
After Width: | Height: | Size: 311 B |
BIN
app/src/main/res/drawable-xxhdpi/search_music_filled.png
Normal file
|
After Width: | Height: | Size: 409 B |