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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.1.1"
|
||||
val rosettaVersionCode = 13 // Increment on each release
|
||||
val rosettaVersionName = "1.1.2"
|
||||
val rosettaVersionCode = 14 // Increment on each release
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
|
||||
@@ -54,7 +54,7 @@ class KeyboardTransitionCoordinator {
|
||||
var keyboardHeight by mutableStateOf(0.dp)
|
||||
var emojiHeight by mutableStateOf(0.dp)
|
||||
|
||||
// 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji
|
||||
// Максимальная высота клавиатуры (защищает от промежуточных значений при анимации закрытия)
|
||||
private var maxKeyboardHeight by mutableStateOf(0.dp)
|
||||
|
||||
// ============ Флаги видимости ============
|
||||
@@ -84,8 +84,10 @@ class KeyboardTransitionCoordinator {
|
||||
currentState = TransitionState.KEYBOARD_TO_EMOJI
|
||||
isTransitioning = true
|
||||
|
||||
// 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры)
|
||||
if (maxKeyboardHeight > 0.dp) {
|
||||
// Устанавливаем emojiHeight = текущая высота клавиатуры (клавиатура ещё открыта)
|
||||
if (keyboardHeight > 0.dp) {
|
||||
emojiHeight = keyboardHeight
|
||||
} else if (maxKeyboardHeight > 0.dp) {
|
||||
emojiHeight = maxKeyboardHeight
|
||||
}
|
||||
|
||||
@@ -234,7 +236,7 @@ class KeyboardTransitionCoordinator {
|
||||
if (height > 100.dp && height != keyboardHeight) {
|
||||
keyboardHeight = height
|
||||
|
||||
// 🔥 Сохраняем максимальную высоту
|
||||
// Обновляем maxKeyboardHeight только вверх (защита от промежуточных значений анимации)
|
||||
if (height > maxKeyboardHeight) {
|
||||
maxKeyboardHeight = height
|
||||
}
|
||||
@@ -244,14 +246,14 @@ class KeyboardTransitionCoordinator {
|
||||
emojiHeight = height
|
||||
}
|
||||
} else if (height == 0.dp && keyboardHeight != 0.dp) {
|
||||
// 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты
|
||||
|
||||
// Восстанавливаем emojiHeight до максимальной высоты
|
||||
if (maxKeyboardHeight > 0.dp) {
|
||||
// Клавиатура закрывается.
|
||||
// Если emoji уже показан (keyboard→emoji переход), НЕ трогаем emojiHeight —
|
||||
// requestShowEmoji() уже установил правильное значение = текущая высота клавиатуры.
|
||||
// Восстанавливаем из maxKeyboardHeight только если emoji НЕ виден (обычное закрытие).
|
||||
if (!isEmojiVisible && !isEmojiBoxVisible && maxKeyboardHeight > 0.dp) {
|
||||
emojiHeight = maxKeyboardHeight
|
||||
}
|
||||
|
||||
// Обнуляем keyboardHeight
|
||||
keyboardHeight = 0.dp
|
||||
}
|
||||
|
||||
@@ -272,7 +274,8 @@ class KeyboardTransitionCoordinator {
|
||||
* emojiHeight должна оставаться фиксированной!
|
||||
*/
|
||||
fun syncHeights() {
|
||||
// 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji
|
||||
// Синхронизируем только вверх — при закрытии клавиатуры промежуточные значения
|
||||
// не должны уменьшать emojiHeight. Точная высота ставится в requestShowEmoji().
|
||||
if (keyboardHeight > 100.dp && keyboardHeight > emojiHeight) {
|
||||
emojiHeight = keyboardHeight
|
||||
}
|
||||
|
||||
@@ -676,6 +676,7 @@ fun MainScreen(
|
||||
prefsManager
|
||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||
.collectAsState(initial = "avatar")
|
||||
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
|
||||
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||
|
||||
// AvatarRepository для работы с аватарами
|
||||
@@ -894,13 +895,18 @@ fun MainScreen(
|
||||
isVisible = isThemeVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 2
|
||||
layer = 2,
|
||||
deferToChildren = true
|
||||
) {
|
||||
ThemeScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
currentThemeMode = themeMode,
|
||||
currentWallpaperId = chatWallpaperId,
|
||||
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)
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
chatWallpaperId = chatWallpaperId,
|
||||
avatarRepository = avatarRepository,
|
||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
||||
)
|
||||
@@ -1072,6 +1079,9 @@ fun MainScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
accountName = accountName,
|
||||
accountUsername = accountUsername,
|
||||
avatarRepository = avatarRepository,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
|
||||
onGroupOpened = { groupUser ->
|
||||
navStack =
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.PacketGroupLeave
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
@@ -27,6 +28,8 @@ class GroupRepository private constructor(context: Context) {
|
||||
private val messageDao = db.messageDao()
|
||||
private val dialogDao = db.dialogDao()
|
||||
|
||||
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
||||
|
||||
companion object {
|
||||
private const val GROUP_PREFIX = "#group:"
|
||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||
@@ -159,6 +162,20 @@ class GroupRepository private constructor(context: Context) {
|
||||
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? {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return null
|
||||
@@ -176,11 +193,13 @@ class GroupRepository private constructor(context: Context) {
|
||||
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
||||
?: return null
|
||||
|
||||
return GroupInviteInfoResult(
|
||||
val result = GroupInviteInfoResult(
|
||||
groupId = groupId,
|
||||
membersCount = response.membersCount.coerceAtLeast(0),
|
||||
status = response.groupStatus
|
||||
)
|
||||
inviteInfoCache[groupId] = result
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun createGroup(
|
||||
|
||||
@@ -27,6 +27,7 @@ class PreferencesManager(private val context: Context) {
|
||||
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
|
||||
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
||||
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
|
||||
|
||||
// Notifications
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -17,28 +17,27 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
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
|
||||
|
||||
Синхронизация и стабильность
|
||||
- Исправлены пропуски сообщений при массовой синхронизации личных и групповых чатов
|
||||
- Sync теперь не продвигает курсор батча при ошибках обработки и делает безопасные ретраи
|
||||
- Исправлены кейсы, где requests зависели от состояния устройства, а не аккаунта
|
||||
- Rosetta Updates и Safe исключены из requests
|
||||
Чат и интерфейс
|
||||
- Улучшены индикаторы доставки сообщений
|
||||
- Менеджер загрузки файлов с отображением прогресса
|
||||
- Открытие файлов в профиле пользователя (локальные, загруженные, из blob)
|
||||
- Новые обои для чатов
|
||||
- Улучшена работа камеры и управление состоянием UI
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -490,6 +490,42 @@ interface MessageDao {
|
||||
"""
|
||||
)
|
||||
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 для работы с диалогами */
|
||||
|
||||
@@ -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.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.VerifiedBadge
|
||||
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.utils.MediaUtils
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
|
||||
currentUserName: String = "",
|
||||
totalUnreadFromOthers: Int = 0,
|
||||
isDarkTheme: Boolean,
|
||||
chatWallpaperId: String = "",
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onImageViewerChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
|
||||
|
||||
// UI Theme
|
||||
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 secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
val headerIconColor = Color.White
|
||||
@@ -633,6 +638,14 @@ fun ChatDetailScreen(
|
||||
isScreenActive = 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 -> {}
|
||||
}
|
||||
}
|
||||
@@ -1806,14 +1819,29 @@ fun ChatDetailScreen(
|
||||
) { paddingValues ->
|
||||
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 🔥 Column структура - список сжимается когда клавиатура
|
||||
// открывается
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
// 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(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Список сообщений - занимает всё доступное место
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Плавная анимация bottom padding при входе/выходе из selection mode
|
||||
@@ -2487,27 +2515,6 @@ fun ChatDetailScreen(
|
||||
} // Закрытие Box wrapper для Scaffold content
|
||||
} // Закрытие 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) {
|
||||
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) {
|
||||
InAppCameraScreen(
|
||||
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)
|
||||
|
||||
if (update.dialogKey == currentDialogKey) {
|
||||
if (!isDialogActive) return@collect
|
||||
when (update.status) {
|
||||
DeliveryStatus.DELIVERED -> {
|
||||
// Обновляем конкретное сообщение
|
||||
@@ -1157,7 +1158,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
when (entity.delivered) {
|
||||
0 -> MessageStatus.SENDING
|
||||
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
||||
2 -> MessageStatus.SENT
|
||||
2 -> MessageStatus.ERROR
|
||||
3 -> MessageStatus.READ
|
||||
else -> MessageStatus.SENT
|
||||
},
|
||||
@@ -2508,8 +2509,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
saveDialog(text, timestamp)
|
||||
} catch (e: Exception) {
|
||||
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 {
|
||||
isSending = false
|
||||
}
|
||||
@@ -3711,7 +3715,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
||||
} 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 {
|
||||
isSending = false
|
||||
}
|
||||
@@ -3941,7 +3947,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
saveDialog("\$a=Avatar", timestamp)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
updateMessageStatus(messageId, MessageStatus.ERROR)
|
||||
android.widget.Toast.makeText(
|
||||
getApplication(),
|
||||
"Failed to send avatar: ${e.message}",
|
||||
@@ -3949,6 +3955,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
.show()
|
||||
}
|
||||
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
|
||||
saveDialog("\$a=Avatar", timestamp)
|
||||
} finally {
|
||||
isSending = false
|
||||
}
|
||||
@@ -4097,7 +4105,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
content = encryptedContent,
|
||||
timestamp = timestamp,
|
||||
chachaKey = encryptedKey,
|
||||
read = if (isFromMe) 1 else 0,
|
||||
read = if (isFromMe && opponent == account) 1 else 0,
|
||||
fromMe = if (isFromMe) 1 else 0,
|
||||
delivered = delivered,
|
||||
messageId = finalMessageId,
|
||||
|
||||
@@ -442,7 +442,11 @@ fun ChatsListScreen(
|
||||
val syncInProgress by ProtocolManager.syncInProgress.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()
|
||||
|
||||
// Load dialogs when account is available
|
||||
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
|
||||
},
|
||||
actions = {
|
||||
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(
|
||||
onClick = {
|
||||
if (protocolState ==
|
||||
@@ -3800,30 +3814,14 @@ fun DialogItemContent(
|
||||
// - lastMessageDelivered == 0 → часики
|
||||
// (отправляется)
|
||||
// - lastMessageDelivered == 2 → ошибка
|
||||
when (dialog.lastMessageDelivered) {
|
||||
2 -> {
|
||||
// ERROR - показываем иконку ошибки
|
||||
Icon(
|
||||
imageVector =
|
||||
TablerIcons
|
||||
.AlertCircle,
|
||||
contentDescription =
|
||||
"Sending failed",
|
||||
tint =
|
||||
Color(
|
||||
0xFFFF3B30
|
||||
), // iOS красный
|
||||
modifier =
|
||||
Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(4.dp)
|
||||
)
|
||||
}
|
||||
3 -> {
|
||||
// READ (delivered=3) - две синие
|
||||
// галочки
|
||||
val isReadByOpponent =
|
||||
dialog.lastMessageDelivered == 3 ||
|
||||
(dialog.lastMessageDelivered == 1 &&
|
||||
dialog.lastMessageRead == 1)
|
||||
|
||||
when {
|
||||
isReadByOpponent -> {
|
||||
// READ - две синие галочки
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(20.dp)
|
||||
@@ -3867,7 +3865,11 @@ fun DialogItemContent(
|
||||
Modifier.width(4.dp)
|
||||
)
|
||||
}
|
||||
1 -> {
|
||||
dialog.lastMessageDelivered == 2 -> {
|
||||
// ERROR - не показываем статус рядом с временем,
|
||||
// error badge показывается внизу справа (как в Telegram)
|
||||
}
|
||||
dialog.lastMessageDelivered == 1 -> {
|
||||
// DELIVERED - одна серая галочка
|
||||
Icon(
|
||||
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
|
||||
if (dialog.unreadCount > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Full-screen connection logs viewer.
|
||||
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionLogsScreen(
|
||||
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
LaunchedEffect(logs.size) {
|
||||
if (logs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(logs.size - 1)
|
||||
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
|
||||
.background(bgColor)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
// Header
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Clear button
|
||||
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Trash,
|
||||
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
||||
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Status bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Logs list
|
||||
if (logs.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
|
||||
import android.app.Activity
|
||||
import android.widget.Toast
|
||||
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.tween
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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.network.AttachmentType
|
||||
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.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
@@ -149,6 +159,40 @@ private data class GroupSharedStats(
|
||||
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(
|
||||
val key: String,
|
||||
val attachment: MessageAttachment,
|
||||
@@ -160,12 +204,14 @@ private data class GroupMediaItem(
|
||||
)
|
||||
|
||||
private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
|
||||
private val KEY_IMAGE_COLORS = listOf(
|
||||
Color(0xFFD0EBFF),
|
||||
Color(0xFFA5D8FF),
|
||||
Color(0xFF74C0FC),
|
||||
Color(0xFF4DABF7),
|
||||
Color(0xFF339AF0)
|
||||
|
||||
// Identicon colors — same palette for both themes (like Telegram)
|
||||
// White stays white so the pattern is visible on dark backgrounds
|
||||
private val IDENTICON_COLORS = intArrayOf(
|
||||
0xFFFFFFFF.toInt(),
|
||||
0xFFD0E8FF.toInt(),
|
||||
0xFF228BE6.toInt(),
|
||||
0xFF1971C2.toInt()
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -180,8 +226,6 @@ fun GroupInfoScreen(
|
||||
onGroupLeft: () -> Unit = {},
|
||||
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val view = LocalView.current
|
||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
@@ -242,16 +286,22 @@ fun GroupInfoScreen(
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showLeaveConfirm 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 encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||
var membersLoading by remember { mutableStateOf(false) }
|
||||
var isMuted by remember { mutableStateOf(false) }
|
||||
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
|
||||
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
|
||||
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?>(
|
||||
initialValue = null,
|
||||
@@ -341,61 +391,150 @@ fun GroupInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshMembers() {
|
||||
fun refreshMembers(force: Boolean = false, showLoader: Boolean = true) {
|
||||
if (normalizedGroupId.isBlank()) return
|
||||
if (isRefreshingMembers && !force) return
|
||||
|
||||
scope.launch {
|
||||
membersLoading = true
|
||||
val fetchedMembers = withContext(Dispatchers.IO) {
|
||||
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
|
||||
if (!force) {
|
||||
GroupMembersMemoryCache.getFresh(membersCacheKey)?.let { cached ->
|
||||
members = cached.members
|
||||
memberInfoByKey.clear()
|
||||
memberInfoByKey.putAll(cached.memberInfoByKey)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
members = fetchedMembers.distinct()
|
||||
membersLoading = false
|
||||
|
||||
if (members.isEmpty()) 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) {
|
||||
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
|
||||
}
|
||||
val distinctMembers = fetchedMembers.distinct()
|
||||
if (distinctMembers.isNotEmpty() || members.isEmpty()) {
|
||||
members = distinctMembers
|
||||
}
|
||||
|
||||
val resolvedUsers = withContext(Dispatchers.IO) {
|
||||
val resolvedMap = LinkedHashMap<String, SearchUser>()
|
||||
members.forEach { memberKey ->
|
||||
val cached = ProtocolManager.getCachedUserInfo(memberKey)
|
||||
if (cached != null) {
|
||||
resolvedMap[memberKey] = cached
|
||||
} else {
|
||||
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
|
||||
resolvedMap[memberKey] = resolvedUser
|
||||
if (members.isEmpty()) return@launch
|
||||
|
||||
val resolvedUsers = withContext(Dispatchers.IO) {
|
||||
val resolvedMap = LinkedHashMap<String, SearchUser>()
|
||||
members.forEach { memberKey ->
|
||||
val cached = ProtocolManager.getCachedUserInfo(memberKey)
|
||||
if (cached != null) {
|
||||
resolvedMap[memberKey] = cached
|
||||
} else {
|
||||
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
|
||||
resolvedMap[memberKey] = resolvedUser
|
||||
}
|
||||
}
|
||||
}
|
||||
resolvedMap
|
||||
}
|
||||
resolvedMap
|
||||
}
|
||||
if (resolvedUsers.isNotEmpty()) {
|
||||
memberInfoByKey.putAll(resolvedUsers)
|
||||
if (resolvedUsers.isNotEmpty()) {
|
||||
memberInfoByKey.putAll(resolvedUsers)
|
||||
}
|
||||
|
||||
GroupMembersMemoryCache.put(
|
||||
key = membersCacheKey,
|
||||
members = members,
|
||||
memberInfoByKey = memberInfoByKey.toMap()
|
||||
)
|
||||
} finally {
|
||||
if (shouldShowLoader) membersLoading = false
|
||||
isRefreshingMembers = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(normalizedGroupId) {
|
||||
refreshMembers()
|
||||
LaunchedEffect(membersCacheKey) {
|
||||
val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
|
||||
cachedEntry?.let { cached ->
|
||||
members = cached.members
|
||||
memberInfoByKey.clear()
|
||||
memberInfoByKey.putAll(cached.memberInfoByKey)
|
||||
}
|
||||
|
||||
if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
|
||||
refreshMembers(force = true, showLoader = cachedEntry == null)
|
||||
}
|
||||
}
|
||||
|
||||
val onlineCount by remember(members, memberInfoByKey) {
|
||||
// 🟢 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 {
|
||||
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 {
|
||||
val query = searchQuery.trim().lowercase()
|
||||
members.mapIndexed { index, key ->
|
||||
val info = memberInfoByKey[key]
|
||||
val isOnline = memberOnlineStatus[key] == true ||
|
||||
key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
|
||||
val fallbackName = shortPublicKey(key)
|
||||
val displayTitle =
|
||||
info?.title?.takeIf { it.isNotBlank() }
|
||||
?: info?.username?.takeIf { it.isNotBlank() }
|
||||
?: fallbackName
|
||||
val subtitle = when {
|
||||
(info?.online ?: 0) > 0 -> "online"
|
||||
isOnline -> "online"
|
||||
info?.username?.isNotBlank() == true -> "@${info.username}"
|
||||
else -> key.take(18)
|
||||
}
|
||||
@@ -404,14 +543,14 @@ fun GroupInfoScreen(
|
||||
title = displayTitle,
|
||||
subtitle = subtitle,
|
||||
verified = info?.verified ?: 0,
|
||||
online = (info?.online ?: 0) > 0,
|
||||
online = isOnline,
|
||||
isAdmin = index == 0,
|
||||
searchUser = SearchUser(
|
||||
publicKey = key,
|
||||
title = info?.title ?: displayTitle,
|
||||
username = info?.username.orEmpty(),
|
||||
verified = info?.verified ?: 0,
|
||||
online = info?.online ?: 0
|
||||
online = if (isOnline) 1 else 0
|
||||
)
|
||||
)
|
||||
}.filter { member ->
|
||||
@@ -425,7 +564,6 @@ fun GroupInfoScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
||||
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
|
||||
derivedStateOf {
|
||||
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
||||
@@ -435,6 +573,14 @@ fun GroupInfoScreen(
|
||||
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
|
||||
BackHandler {
|
||||
if (showEncryptionPage) {
|
||||
showEncryptionPage = false
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (selectedTab != GroupInfoTab.MEMBERS) {
|
||||
swipedMemberKey = null
|
||||
@@ -541,6 +687,7 @@ fun GroupInfoScreen(
|
||||
}
|
||||
isLeaving = false
|
||||
if (left) {
|
||||
GroupMembersMemoryCache.remove(membersCacheKey)
|
||||
onGroupLeft()
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
|
||||
@@ -580,7 +727,12 @@ fun GroupInfoScreen(
|
||||
if (removed) {
|
||||
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
|
||||
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()
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
|
||||
@@ -605,7 +757,7 @@ fun GroupInfoScreen(
|
||||
return@launch
|
||||
}
|
||||
encryptionKey = key
|
||||
showEncryptionDialog = true
|
||||
showEncryptionPage = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,60 +1267,24 @@ fun GroupInfoScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showEncryptionDialog) {
|
||||
AnimatedVisibility(
|
||||
visible = showEncryptionPage,
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEncryptionDialog = false },
|
||||
title = { Text("Encryption key") },
|
||||
text = {
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DesktopStyleKeyImage(
|
||||
keyRender = encryptionKey,
|
||||
size = 180.dp,
|
||||
radius = 14.dp
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
SelectionContainer {
|
||||
Column {
|
||||
if (displayLines.isNotEmpty()) {
|
||||
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))
|
||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
) {
|
||||
Text("Copy", color = accentColor)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEncryptionDialog = false }) {
|
||||
Text("Close", color = secondaryText)
|
||||
}
|
||||
GroupEncryptionKeyPage(
|
||||
encryptionKey = encryptionKey,
|
||||
displayLines = displayLines,
|
||||
peerTitle = groupTitle,
|
||||
isDarkTheme = isDarkTheme,
|
||||
topSurfaceColor = topSurfaceColor,
|
||||
backgroundColor = backgroundColor,
|
||||
onBack = { showEncryptionPage = false },
|
||||
onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1241,38 +1357,179 @@ fun GroupInfoScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopStyleKeyImage(
|
||||
private fun TelegramStyleIdenticon(
|
||||
keyRender: String,
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
radius: androidx.compose.ui.unit.Dp = 0.dp
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val composition = remember(keyRender) {
|
||||
buildList(64) {
|
||||
val source = if (keyRender.isBlank()) "rosetta" else keyRender
|
||||
for (i in 0 until 64) {
|
||||
val code = source[i % source.length].code
|
||||
val colorIndex = code % KEY_IMAGE_COLORS.size
|
||||
add(KEY_IMAGE_COLORS[colorIndex])
|
||||
}
|
||||
}
|
||||
val palette = IDENTICON_COLORS
|
||||
|
||||
// Convert key string to byte array, then use 2-bit grouping like Telegram's IdenticonDrawable
|
||||
val keyBytes = remember(keyRender) {
|
||||
val source = keyRender.ifBlank { "rosetta" }
|
||||
source.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(KEY_IMAGE_COLORS.first())
|
||||
) {
|
||||
val cells = 8
|
||||
val cells = 12
|
||||
val cellSize = this.size.minDimension / cells.toFloat()
|
||||
for (i in 0 until 64) {
|
||||
val row = i / cells
|
||||
val col = i % cells
|
||||
drawRect(
|
||||
color = composition[i],
|
||||
topLeft = Offset(col * cellSize, row * cellSize),
|
||||
size = Size(cellSize, cellSize)
|
||||
)
|
||||
var bitPointer = 0
|
||||
for (iy in 0 until 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(
|
||||
color = Color(palette[colorIndex]),
|
||||
topLeft = Offset(ix * cellSize, iy * 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()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
|
||||
// Telegram-style: each char → XOR 27 → 2-char hex, grouped as:
|
||||
// "ab cd ef 12 34 56 78 9a" (4 pairs + double space + 4 pairs per line)
|
||||
val hexPairs = normalized.map { symbol ->
|
||||
(symbol.code xor 27).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
normalized.chunked(16).forEach { chunk ->
|
||||
val bytes = mutableListOf<String>()
|
||||
chunk.forEach { symbol ->
|
||||
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
|
||||
bytes.add(encoded)
|
||||
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(bytes.joinToString(" "))
|
||||
lines.add(sb.toString())
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -1,61 +1,186 @@
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.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.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
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.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Alignment
|
||||
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.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.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.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.network.GroupStatus
|
||||
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.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private enum class GroupSetupStep {
|
||||
DETAILS,
|
||||
DESCRIPTION
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
fun GroupSetupScreen(
|
||||
isDarkTheme: Boolean,
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
accountName: String,
|
||||
accountUsername: String,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onBack: () -> Unit,
|
||||
onGroupOpened: (SearchUser) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
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 title by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
var inviteString by remember { mutableStateOf("") }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var description by rememberSaveable { mutableStateOf("") }
|
||||
var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var isLoading by rememberSaveable { mutableStateOf(false) }
|
||||
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) {
|
||||
onGroupOpened(
|
||||
@@ -77,13 +202,6 @@ fun GroupSetupScreen(
|
||||
description = description.trim()
|
||||
)
|
||||
|
||||
suspend fun joinGroup() =
|
||||
GroupRepository.getInstance(context).joinGroup(
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
inviteString = inviteString.trim()
|
||||
)
|
||||
|
||||
fun mapError(status: GroupStatus, fallback: String): String {
|
||||
return when (status) {
|
||||
GroupStatus.BANNED -> "You are banned in this group"
|
||||
@@ -92,157 +210,615 @@ fun GroupSetupScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Groups", fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
fun handleBack() {
|
||||
if (isLoading) return
|
||||
errorText = null
|
||||
if (step == GroupSetupStep.DESCRIPTION) {
|
||||
step = GroupSetupStep.DETAILS
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
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))
|
||||
BackHandler(onBack = ::handleBack)
|
||||
|
||||
if (selectedTab == 0) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Group title") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
minLines = 3,
|
||||
maxLines = 4
|
||||
)
|
||||
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(),
|
||||
enabled = title.trim().isNotEmpty() && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Create Group")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = inviteString,
|
||||
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
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = inviteString.trim().isNotEmpty() && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text("Join Group")
|
||||
}
|
||||
}
|
||||
}
|
||||
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() }
|
||||
|
||||
if (!errorText.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = errorText ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text =
|
||||
if (selectedTab == 0) {
|
||||
"Creates a new private group and joins it automatically."
|
||||
} else {
|
||||
"Paste a full invite string that starts with #group:."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
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 ->
|
||||
// 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(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
if (step == GroupSetupStep.DETAILS) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
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,
|
||||
onValueChange = { newValue -> title = newValue.take(80) },
|
||||
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(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (title.isBlank()) {
|
||||
Text(
|
||||
text = "Group name",
|
||||
color = secondaryTextColor.copy(alpha = 0.88f),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
}
|
||||
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,
|
||||
onValueChange = { newValue -> description = newValue.take(400) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
.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
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedTextColor = primaryTextColor,
|
||||
unfocusedTextColor = primaryTextColor,
|
||||
focusedContainerColor = sectionColor,
|
||||
unfocusedContainerColor = sectionColor,
|
||||
disabledContainerColor = sectionColor,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedPlaceholderColor = secondaryTextColor,
|
||||
unfocusedPlaceholderColor = secondaryTextColor,
|
||||
cursorColor = accentColor
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Description is optional. You can change it later in group info.",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
if (!errorText.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = errorText.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
if (step == GroupSetupStep.DETAILS) {
|
||||
if (canGoNext) {
|
||||
errorText = null
|
||||
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 {
|
||||
errorText = mapError(result.status, result.error ?: "Cannot create group")
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
|
||||
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 lifecycleOwner = LocalLifecycleOwner.current
|
||||
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) }
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -145,6 +147,21 @@ internal fun CameraGridItem(
|
||||
) == 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(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
@@ -164,17 +181,24 @@ internal fun CameraGridItem(
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
previewUseCase?.let { existing ->
|
||||
try {
|
||||
provider.unbind(existing)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview
|
||||
)
|
||||
previewUseCase = preview
|
||||
} catch (_: Exception) {
|
||||
// Camera init failed
|
||||
}
|
||||
|
||||
@@ -440,6 +440,17 @@ fun ChatAttachAlert(
|
||||
} else {
|
||||
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
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||
var downloadProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
@@ -1465,7 +1464,30 @@ fun FileAttachment(
|
||||
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
||||
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) {
|
||||
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
return@LaunchedEffect
|
||||
}
|
||||
downloadStatus = if (isDownloadTag(preview)) {
|
||||
// Проверяем, был ли файл уже скачан ранее
|
||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||
@@ -1507,76 +1529,20 @@ fun FileAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||
val download: () -> Unit = {
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
scope.launch {
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
// Streaming: скачиваем во temp file, не в память
|
||||
val success =
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
downloadProgress = 0.5f
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
|
||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||
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
|
||||
}
|
||||
}
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
downloadProgress = 0f
|
||||
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||
context = context,
|
||||
attachmentId = attachment.id,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1623,7 +1589,9 @@ fun FileAttachment(
|
||||
) {
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||
// Determinate progress like Telegram
|
||||
CircularProgressIndicator(
|
||||
progress = downloadProgress.coerceIn(0f, 1f),
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
@@ -1693,10 +1661,14 @@ fun FileAttachment(
|
||||
|
||||
when (downloadStatus) {
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
AnimatedDotsText(
|
||||
baseText = "Downloading",
|
||||
color = statusColor,
|
||||
fontSize = 12.sp
|
||||
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||
// CDN download maps to progress 0..0.8
|
||||
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||
Text(
|
||||
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||
fontSize = 12.sp,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
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.Check
|
||||
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.PersonAdd
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -592,11 +593,19 @@ fun MessageBubble(
|
||||
.IMAGE
|
||||
}
|
||||
|
||||
val isStandaloneGroupInvite =
|
||||
message.attachments.isEmpty() &&
|
||||
message.replyData == null &&
|
||||
message.forwardedMessages.isEmpty() &&
|
||||
message.text.isNotBlank() &&
|
||||
isGroupInviteCode(message.text)
|
||||
|
||||
// Для сообщений только с фото - минимальный padding и тонкий border
|
||||
// Для фото + caption - padding только внизу для текста
|
||||
val bubblePadding =
|
||||
when {
|
||||
isSafeSystemMessage -> PaddingValues(0.dp)
|
||||
isStandaloneGroupInvite -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||
hasOnlyMedia -> PaddingValues(0.dp)
|
||||
hasImageWithCaption -> PaddingValues(0.dp)
|
||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||
@@ -676,6 +685,8 @@ fun MessageBubble(
|
||||
val bubbleWidthModifier =
|
||||
if (isSafeSystemMessage) {
|
||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||
} else if (isStandaloneGroupInvite) {
|
||||
Modifier.widthIn(min = 180.dp, max = 260.dp)
|
||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||
Modifier.width(
|
||||
photoWidth
|
||||
@@ -703,46 +714,52 @@ fun MessageBubble(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.clip(bubbleShape)
|
||||
.then(
|
||||
if (hasOnlyMedia) {
|
||||
Modifier.border(
|
||||
width = bubbleBorderWidth,
|
||||
color =
|
||||
if (message.isOutgoing
|
||||
) {
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.15f
|
||||
)
|
||||
} else {
|
||||
if (isDarkTheme
|
||||
)
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.1f
|
||||
)
|
||||
else
|
||||
Color.Black
|
||||
.copy(
|
||||
alpha =
|
||||
0.08f
|
||||
)
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
} else if (isSafeSystemMessage) {
|
||||
Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2D)
|
||||
else Color(0xFFF0F0F4)
|
||||
)
|
||||
if (false) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.background(bubbleColor)
|
||||
Modifier.clip(bubbleShape)
|
||||
.then(
|
||||
if (hasOnlyMedia) {
|
||||
Modifier.border(
|
||||
width = bubbleBorderWidth,
|
||||
color =
|
||||
if (message.isOutgoing
|
||||
) {
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.15f
|
||||
)
|
||||
} else {
|
||||
if (isDarkTheme
|
||||
)
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.1f
|
||||
)
|
||||
else
|
||||
Color.Black
|
||||
.copy(
|
||||
alpha =
|
||||
0.08f
|
||||
)
|
||||
},
|
||||
shape = bubbleShape
|
||||
)
|
||||
} else if (isSafeSystemMessage) {
|
||||
Modifier.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2D)
|
||||
else Color(0xFFF0F0F4)
|
||||
)
|
||||
} else {
|
||||
Modifier.background(bubbleColor)
|
||||
}
|
||||
)
|
||||
.padding(bubblePadding)
|
||||
}
|
||||
)
|
||||
.padding(bubblePadding)
|
||||
) {
|
||||
if (isSafeSystemMessage) {
|
||||
SafeSystemMessageCard(
|
||||
@@ -1045,35 +1062,12 @@ fun MessageBubble(
|
||||
accountPublicKey = currentUserPublicKey,
|
||||
accountPrivateKey = privateKey,
|
||||
actionsEnabled = !isSelectionMode,
|
||||
timestamp = message.timestamp,
|
||||
messageStatus = displayStatus,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete,
|
||||
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 {
|
||||
// Telegram-style: текст + время с автоматическим
|
||||
// переносом
|
||||
@@ -1275,6 +1269,10 @@ private fun GroupInviteInlineCard(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
actionsEnabled: Boolean,
|
||||
timestamp: Date,
|
||||
messageStatus: MessageStatus,
|
||||
onRetry: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onOpenGroup: (SearchUser) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -1284,9 +1282,10 @@ private fun GroupInviteInlineCard(
|
||||
val normalizedInvite = remember(inviteText) { inviteText.trim() }
|
||||
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
|
||||
|
||||
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
|
||||
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
|
||||
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
|
||||
val cachedInfo = remember(normalizedInvite) { parsedInvite?.let { groupRepository.getCachedInviteInfo(it.groupId) } }
|
||||
var status by remember(normalizedInvite) { mutableStateOf(cachedInfo?.status ?: GroupStatus.NOT_JOINED) }
|
||||
var membersCount by remember(normalizedInvite) { mutableStateOf(cachedInfo?.membersCount ?: 0) }
|
||||
var statusLoading by remember(normalizedInvite) { mutableStateOf(cachedInfo == null) }
|
||||
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(normalizedInvite, accountPublicKey) {
|
||||
@@ -1297,7 +1296,9 @@ private fun GroupInviteInlineCard(
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
statusLoading = true
|
||||
if (cachedInfo == null) {
|
||||
statusLoading = true
|
||||
}
|
||||
|
||||
val localGroupExists =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -1314,12 +1315,13 @@ private fun GroupInviteInlineCard(
|
||||
}
|
||||
|
||||
membersCount = inviteInfo?.membersCount ?: 0
|
||||
status =
|
||||
when {
|
||||
localGroupExists -> GroupStatus.JOINED
|
||||
inviteInfo != null -> inviteInfo.status
|
||||
else -> GroupStatus.NOT_JOINED
|
||||
}
|
||||
val newStatus = when {
|
||||
localGroupExists -> GroupStatus.JOINED
|
||||
inviteInfo != null -> inviteInfo.status
|
||||
else -> GroupStatus.NOT_JOINED
|
||||
}
|
||||
status = newStatus
|
||||
groupRepository.cacheInviteInfo(parsedInvite.groupId, newStatus, membersCount)
|
||||
|
||||
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 =
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
||||
else if (isDarkTheme) Color(0xFFA9AFBA)
|
||||
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 =
|
||||
when (status) {
|
||||
@@ -1441,6 +1429,7 @@ private fun GroupInviteInlineCard(
|
||||
|
||||
if (joinResult.success) {
|
||||
status = GroupStatus.JOINED
|
||||
groupRepository.cacheInviteInfo(parsedInvite.groupId, GroupStatus.JOINED, membersCount)
|
||||
openParsedGroup()
|
||||
} else {
|
||||
status = joinResult.status
|
||||
@@ -1455,28 +1444,27 @@ private fun GroupInviteInlineCard(
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardBackground,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
||||
) {
|
||||
Column {
|
||||
// Icon + Title row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Group icon circle
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.15f)
|
||||
else accentColor.copy(alpha = 0.12f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
imageVector = Icons.Default.Groups,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
tint = if (isOutgoing) Color.White else accentColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1485,63 +1473,93 @@ private fun GroupInviteInlineCard(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 14.sp,
|
||||
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color(0xFF1A1A1A),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else {
|
||||
accentColor.copy(alpha = 0.14f)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.8.dp,
|
||||
color = accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = accentColor,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Action button (full width)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color = if (isOutgoing) Color.White.copy(alpha = 0.18f)
|
||||
else accentColor.copy(alpha = 0.10f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(13.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = if (isOutgoing) Color.White else accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = if (isOutgoing) Color.White else accentColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = if (isOutgoing) Color.White else accentColor,
|
||||
fontSize = 13.sp,
|
||||
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 = {},
|
||||
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 =
|
||||
status == MessageStatus.SENDING &&
|
||||
timestamp > 0 &&
|
||||
@@ -1688,18 +1719,30 @@ fun AnimatedMessageStatus(
|
||||
label = "statusIcon"
|
||||
) { currentStatus ->
|
||||
if (currentStatus == MessageStatus.ERROR) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.AlertCircle,
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
// Telegram-style: red filled circle with white "!" inside
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(iconSize)
|
||||
Modifier.padding(start = 4.dp)
|
||||
.size(iconSize)
|
||||
.align(Alignment.CenterStart)
|
||||
.scale(scale)
|
||||
.background(
|
||||
color = Color(0xFFE53935),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable {
|
||||
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 {
|
||||
if (currentStatus == MessageStatus.READ) {
|
||||
Box(
|
||||
@@ -1746,39 +1789,76 @@ fun AnimatedMessageStatus(
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showErrorMenu,
|
||||
onDismissRequest = { showErrorMenu = false }
|
||||
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
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Retry") },
|
||||
onClick = {
|
||||
showErrorMenu = false
|
||||
onRetry()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = TelegramIcons.Retry,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showErrorMenu,
|
||||
onDismissRequest = { showErrorMenu = false },
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 196.dp)
|
||||
.background(menuBgColor)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.clickable {
|
||||
showErrorMenu = false
|
||||
onRetry()
|
||||
}
|
||||
.padding(horizontal = 18.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Retry,
|
||||
contentDescription = null,
|
||||
tint = menuIconColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(19.dp))
|
||||
Text(
|
||||
text = "Retry",
|
||||
color = menuTextColor,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete", color = Color(0xFFE53935)) },
|
||||
onClick = {
|
||||
showErrorMenu = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = TelegramIcons.Delete,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE53935),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.clickable {
|
||||
showErrorMenu = false
|
||||
onDelete()
|
||||
}
|
||||
.padding(horizontal = 18.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Delete,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF3B30),
|
||||
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 lifecycleOwner = LocalLifecycleOwner.current
|
||||
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
|
||||
val iconScale = remember { Animatable(0f) }
|
||||
@@ -1699,6 +1701,21 @@ private fun CameraGridItem(
|
||||
) == 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(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
@@ -1719,7 +1736,8 @@ private fun CameraGridItem(
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val provider = cameraProviderFuture.get()
|
||||
cameraProvider = provider
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
@@ -1728,12 +1746,18 @@ private fun CameraGridItem(
|
||||
// Use back camera
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
previewUseCase?.let { existing ->
|
||||
try {
|
||||
provider.unbind(existing)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview
|
||||
)
|
||||
previewUseCase = preview
|
||||
} catch (e: Exception) {
|
||||
// Camera init failed
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ fun Message.toChatMessage() = ChatMessage(
|
||||
status = when (deliveryStatus) {
|
||||
DeliveryStatus.WAITING -> MessageStatus.SENDING
|
||||
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
|
||||
DeliveryStatus.ERROR -> MessageStatus.SENT
|
||||
DeliveryStatus.ERROR -> MessageStatus.ERROR
|
||||
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,
|
||||
swipeEnabled: Boolean = true,
|
||||
propagateBackgroundProgress: Boolean = true,
|
||||
deferToChildren: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
||||
@@ -243,7 +244,7 @@ fun SwipeBackContainer(
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) {
|
||||
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut, deferToChildren) {
|
||||
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
|
||||
|
||||
val velocityTracker = VelocityTracker()
|
||||
@@ -268,12 +269,17 @@ fun SwipeBackContainer(
|
||||
var totalDragY = 0f
|
||||
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) {
|
||||
val event =
|
||||
awaitPointerEvent(
|
||||
val pass =
|
||||
if (startedSwipe || !deferToChildren)
|
||||
PointerEventPass.Initial
|
||||
)
|
||||
else PointerEventPass.Main
|
||||
val event = awaitPointerEvent(pass)
|
||||
val change =
|
||||
event.changes.firstOrNull {
|
||||
it.id == down.id
|
||||
@@ -289,6 +295,9 @@ fun SwipeBackContainer(
|
||||
totalDragY += dragDelta.y
|
||||
|
||||
if (!passedSlop) {
|
||||
// Child (e.g. LazyRow) already consumed — let it handle
|
||||
if (change.isConsumed) break
|
||||
|
||||
val totalDistance =
|
||||
kotlin.math.sqrt(
|
||||
totalDragX *
|
||||
|
||||
@@ -19,6 +19,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
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.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -98,6 +100,7 @@ import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
@@ -183,9 +186,30 @@ fun OtherProfileScreen(
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
||||
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) {
|
||||
onSwipeBackEnabledChanged(!showImageViewer)
|
||||
onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
@@ -711,217 +735,218 @@ fun OtherProfileScreen(
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// TAB CONTENT — inlined directly into LazyColumn items
|
||||
// for true virtualization (only visible items compose)
|
||||
// TAB CONTENT — HorizontalPager for swipe between tabs
|
||||
// On first tab (Media) swipe-right triggers swipe-back;
|
||||
// on other tabs swipe-right goes to the previous tab.
|
||||
// ══════════════════════════════════════════════════════
|
||||
when (selectedTab) {
|
||||
OtherProfileTab.MEDIA -> {
|
||||
if (sharedContent.mediaPhotos.isEmpty()) {
|
||||
item(key = "media_empty") {
|
||||
OtherProfileEmptyState(
|
||||
animationAssetPath = "lottie/saved.json",
|
||||
title = "No shared media yet",
|
||||
subtitle = "Photos from your chat will appear here.",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = mediaIndexedRows,
|
||||
key = { (idx, _) -> "media_row_$idx" }
|
||||
) { (rowIdx, rowPhotos) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||
) {
|
||||
rowPhotos.forEachIndexed { colIdx, media ->
|
||||
val globalIndex = rowIdx * mediaColumns + colIdx
|
||||
item(key = "tab_pager") {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) { page ->
|
||||
when (page) {
|
||||
// ── MEDIA ──
|
||||
0 -> {
|
||||
if (sharedContent.mediaPhotos.isEmpty()) {
|
||||
OtherProfileEmptyState(
|
||||
animationAssetPath = "lottie/saved.json",
|
||||
title = "No shared media yet",
|
||||
subtitle = "Photos from your chat will appear here.",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||
) {
|
||||
rowPhotos.forEachIndexed { colIdx, media ->
|
||||
val globalIndex = rowIdx * mediaColumns + colIdx
|
||||
|
||||
// Check cache first
|
||||
val cachedBitmap = mediaBitmapStates[media.key]
|
||||
?: SharedMediaBitmapCache.get(media.key)
|
||||
val cachedBitmap = mediaBitmapStates[media.key]
|
||||
?: SharedMediaBitmapCache.get(media.key)
|
||||
|
||||
// Only launch decode for items not yet cached
|
||||
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
|
||||
LaunchedEffect(media.key) {
|
||||
mediaDecodeSemaphore.withPermit {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
resolveSharedPhotoBitmap(
|
||||
context = context,
|
||||
media = media,
|
||||
accountPublicKey = activeAccountPublicKey,
|
||||
accountPrivateKey = activeAccountPrivateKey
|
||||
)
|
||||
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
|
||||
LaunchedEffect(media.key) {
|
||||
mediaDecodeSemaphore.withPermit {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
resolveSharedPhotoBitmap(
|
||||
context = context,
|
||||
media = media,
|
||||
accountPublicKey = activeAccountPublicKey,
|
||||
accountPrivateKey = activeAccountPrivateKey
|
||||
)
|
||||
}
|
||||
mediaBitmapStates[media.key] = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
mediaBitmapStates[media.key] = bitmap
|
||||
|
||||
val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key]
|
||||
val model = remember(media.localUri, media.blob) {
|
||||
resolveSharedMediaModel(media.localUri, media.blob)
|
||||
}
|
||||
val previewBitmap = remember(media.preview) {
|
||||
if (media.preview.isNotBlank()) {
|
||||
runCatching {
|
||||
val bytes = Base64.decode(media.preview, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val isLoaded = resolvedBitmap != null || model != null
|
||||
val imageAlpha by animateFloatAsState(
|
||||
targetValue = if (isLoaded) 1f else 0f,
|
||||
animationSpec = tween(300),
|
||||
label = "media_fade"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(mediaCellSize)
|
||||
.clip(RoundedCornerShape(0.dp))
|
||||
.clickable(
|
||||
enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank()
|
||||
) {
|
||||
imageViewerInitialIndex = globalIndex
|
||||
showImageViewer = true
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (previewBitmap != null) {
|
||||
Image(
|
||||
bitmap = previewBitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF1E1E1E)
|
||||
else Color(0xFFECECEC)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (resolvedBitmap != null) {
|
||||
Image(
|
||||
bitmap = resolvedBitmap.asImageBitmap(),
|
||||
contentDescription = "Shared media",
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (model != null) {
|
||||
coil.compose.AsyncImage(
|
||||
model = model,
|
||||
contentDescription = "Shared media",
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(mediaColumns - rowPhotos.size) {
|
||||
Spacer(modifier = Modifier.size(mediaCellSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── FILES ──
|
||||
1 -> {
|
||||
if (sharedContent.files.isEmpty()) {
|
||||
OtherProfileEmptyState(
|
||||
animationAssetPath = "lottie/folder.json",
|
||||
title = "No shared files",
|
||||
subtitle = "Documents from this chat will appear here.",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
||||
|
||||
val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key]
|
||||
val model = remember(media.localUri, media.blob) {
|
||||
resolveSharedMediaModel(media.localUri, media.blob)
|
||||
}
|
||||
// Decode blurred preview from base64 (small image ~4-16px)
|
||||
val previewBitmap = remember(media.preview) {
|
||||
if (media.preview.isNotBlank()) {
|
||||
runCatching {
|
||||
val bytes = Base64.decode(media.preview, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val isLoaded = resolvedBitmap != null || model != null
|
||||
// Animate alpha for smooth fade-in
|
||||
val imageAlpha by animateFloatAsState(
|
||||
targetValue = if (isLoaded) 1f else 0f,
|
||||
animationSpec = tween(300),
|
||||
label = "media_fade"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(mediaCellSize)
|
||||
.clip(RoundedCornerShape(0.dp))
|
||||
.clickable(
|
||||
enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank()
|
||||
) {
|
||||
imageViewerInitialIndex = globalIndex
|
||||
showImageViewer = true
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Blurred preview placeholder (always shown initially)
|
||||
if (previewBitmap != null) {
|
||||
Image(
|
||||
bitmap = previewBitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Fallback shimmer if no preview
|
||||
Box(
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
sharedContent.files.forEachIndexed { index, file ->
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF1E1E1E)
|
||||
else Color(0xFFECECEC)
|
||||
)
|
||||
)
|
||||
}
|
||||
// Full quality image fades in on top
|
||||
if (resolvedBitmap != null) {
|
||||
Image(
|
||||
bitmap = resolvedBitmap.asImageBitmap(),
|
||||
contentDescription = "Shared media",
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (model != null) {
|
||||
coil.compose.AsyncImage(
|
||||
model = model,
|
||||
contentDescription = "Shared media",
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val opened = openSharedFile(context, file)
|
||||
if (!opened) {
|
||||
Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(36.dp).clip(CircleShape)
|
||||
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(text = "${formatFileSize(file.sizeBytes)} • ${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
|
||||
}
|
||||
if (index != sharedContent.files.lastIndex) {
|
||||
Divider(color = fileDivider, thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill remaining cells in last incomplete row
|
||||
repeat(mediaColumns - rowPhotos.size) {
|
||||
Spacer(modifier = Modifier.size(mediaCellSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OtherProfileTab.FILES -> {
|
||||
if (sharedContent.files.isEmpty()) {
|
||||
item(key = "files_empty") {
|
||||
OtherProfileEmptyState(
|
||||
animationAssetPath = "lottie/folder.json",
|
||||
title = "No shared files",
|
||||
subtitle = "Documents from this chat will appear here.",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
||||
// ── LINKS ──
|
||||
2 -> {
|
||||
if (sharedContent.links.isEmpty()) {
|
||||
OtherProfileEmptyState(
|
||||
animationAssetPath = "lottie/earth.json",
|
||||
title = "No shared links",
|
||||
subtitle = "Links from your messages will appear here.",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
||||
|
||||
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file ->
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val opened = openSharedFile(context, file)
|
||||
if (!opened) {
|
||||
Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(36.dp).clip(CircleShape)
|
||||
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
sharedContent.links.forEachIndexed { index, link ->
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}"
|
||||
val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess
|
||||
if (!opened) {
|
||||
Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp)
|
||||
}
|
||||
if (index != sharedContent.links.lastIndex) {
|
||||
Divider(color = linkDivider, thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(text = "${formatFileSize(file.sizeBytes)} • ${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
|
||||
}
|
||||
if (index != sharedContent.files.lastIndex) {
|
||||
Divider(color = fileDivider, thickness = 0.5.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
OtherProfileTab.LINKS -> {
|
||||
if (sharedContent.links.isEmpty()) {
|
||||
item(key = "links_empty") {
|
||||
OtherProfileEmptyState(
|
||||
animationAssetPath = "lottie/earth.json",
|
||||
title = "No shared links",
|
||||
subtitle = "Links from your messages will appear here.",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
|
||||
|
||||
itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link ->
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}"
|
||||
val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess
|
||||
if (!opened) {
|
||||
Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline)
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp)
|
||||
}
|
||||
if (index != sharedContent.links.lastIndex) {
|
||||
Divider(color = linkDivider, thickness = 0.5.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 {
|
||||
val mimeType = inferMimeType(file.fileName)
|
||||
|
||||
// 1. Попробовать localUri
|
||||
if (file.localUri.isNotBlank()) {
|
||||
val local = runCatching { Uri.parse(file.localUri) }.getOrNull()
|
||||
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 extension = file.fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
|
||||
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.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -18,7 +21,6 @@ import androidx.compose.material3.*
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
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.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
@@ -64,8 +68,10 @@ private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
|
||||
fun ThemeScreen(
|
||||
isDarkTheme: Boolean,
|
||||
currentThemeMode: String,
|
||||
currentWallpaperId: String,
|
||||
onBack: () -> Unit,
|
||||
onThemeModeChange: (String) -> Unit
|
||||
onThemeModeChange: (String) -> Unit,
|
||||
onWallpaperChange: (String) -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
@@ -78,7 +84,6 @@ fun ThemeScreen(
|
||||
}
|
||||
|
||||
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 secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -95,11 +100,16 @@ fun ThemeScreen(
|
||||
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||
var wallpaperId by remember { mutableStateOf(currentWallpaperId) }
|
||||
|
||||
LaunchedEffect(currentThemeMode) {
|
||||
themeMode = currentThemeMode
|
||||
}
|
||||
|
||||
LaunchedEffect(currentWallpaperId) {
|
||||
wallpaperId = currentWallpaperId
|
||||
}
|
||||
|
||||
fun resolveThemeIsDark(mode: String): Boolean =
|
||||
when (mode) {
|
||||
"dark" -> true
|
||||
@@ -214,7 +224,7 @@ fun ThemeScreen(
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CHAT PREVIEW - Message bubbles like in real chat
|
||||
// ═══════════════════════════════════════════════════════
|
||||
ChatPreview(isDarkTheme = isDarkTheme)
|
||||
ChatPreview(isDarkTheme = isDarkTheme, wallpaperId = wallpaperId)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
@@ -266,7 +276,27 @@ fun ThemeScreen(
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@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)
|
||||
// Message colors matching real ChatDetailScreen
|
||||
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 otherTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
|
||||
@@ -444,56 +569,65 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
||||
color = chatBgColor,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Incoming message
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
MessageBubble(
|
||||
text = "Hey! How's it going? 👋",
|
||||
time = "10:42",
|
||||
isMe = false,
|
||||
bubbleColor = otherBubbleColor,
|
||||
textColor = otherTextColor,
|
||||
timeColor = otherTimeColor
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (wallpaperResId != null) {
|
||||
Image(
|
||||
painter = painterResource(id = wallpaperResId),
|
||||
contentDescription = "Chat wallpaper preview",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
// Outgoing message
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MessageBubble(
|
||||
text = "Hey! All good, just checking out this new theme 😊",
|
||||
time = "10:43",
|
||||
isMe = true,
|
||||
bubbleColor = myBubbleColor,
|
||||
textColor = myTextColor,
|
||||
timeColor = myTimeColor,
|
||||
checkmarkColor = myCheckColor
|
||||
)
|
||||
}
|
||||
// Incoming message
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
MessageBubble(
|
||||
text = "Hey! How's it going? 👋",
|
||||
time = "10:42",
|
||||
isMe = false,
|
||||
bubbleColor = otherBubbleColor,
|
||||
textColor = otherTextColor,
|
||||
timeColor = otherTimeColor
|
||||
)
|
||||
}
|
||||
|
||||
// Incoming message
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
MessageBubble(
|
||||
text = "Nice! Looks great! 🔥",
|
||||
time = "10:44",
|
||||
isMe = false,
|
||||
bubbleColor = otherBubbleColor,
|
||||
textColor = otherTextColor,
|
||||
timeColor = otherTimeColor
|
||||
)
|
||||
// Outgoing message
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
MessageBubble(
|
||||
text = "Hey! All good, just checking out this new theme 😊",
|
||||
time = "10:43",
|
||||
isMe = true,
|
||||
bubbleColor = myBubbleColor,
|
||||
textColor = myTextColor,
|
||||
timeColor = myTimeColor,
|
||||
checkmarkColor = myCheckColor
|
||||
)
|
||||
}
|
||||
|
||||
// Incoming message
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
MessageBubble(
|
||||
text = "Nice! Looks great! 🔥",
|
||||
time = "10:44",
|
||||
isMe = false,
|
||||
bubbleColor = otherBubbleColor,
|
||||
textColor = otherTextColor,
|
||||
timeColor = otherTimeColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |