feat: Enhance chat UI with group invite handling and new download indicator
- Added support for standalone group invites in MessageBubble component. - Improved bubble padding and width handling for group invites. - Refactored MessageBubble to streamline background and border logic. - Introduced AnimatedDownloadIndicator for a more engaging download experience. - Created ThemeWallpapers data structure to manage chat wallpapers. - Implemented WallpaperSelectorRow and WallpaperSelectorItem for theme customization. - Updated ThemeScreen to allow wallpaper selection and preview. - Added new drawable resources for download and search icons.
@@ -676,6 +676,7 @@ fun MainScreen(
|
|||||||
prefsManager
|
prefsManager
|
||||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||||
.collectAsState(initial = "avatar")
|
.collectAsState(initial = "avatar")
|
||||||
|
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
|
||||||
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||||
|
|
||||||
// AvatarRepository для работы с аватарами
|
// AvatarRepository для работы с аватарами
|
||||||
@@ -899,8 +900,12 @@ fun MainScreen(
|
|||||||
ThemeScreen(
|
ThemeScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
currentThemeMode = themeMode,
|
currentThemeMode = themeMode,
|
||||||
|
currentWallpaperId = chatWallpaperId,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||||
onThemeModeChange = onThemeModeChange
|
onThemeModeChange = onThemeModeChange,
|
||||||
|
onWallpaperChange = { wallpaperId ->
|
||||||
|
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -989,6 +994,7 @@ fun MainScreen(
|
|||||||
} + Screen.ChatDetail(forwardUser)
|
} + Screen.ChatDetail(forwardUser)
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
chatWallpaperId = chatWallpaperId,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
|
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
|
||||||
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
||||||
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
||||||
|
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||||
@@ -100,6 +101,13 @@ class PreferencesManager(private val context: Context) {
|
|||||||
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
|
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val chatWallpaperId: Flow<String> =
|
||||||
|
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
|
||||||
|
|
||||||
|
suspend fun setChatWallpaperId(value: String) {
|
||||||
|
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
|
||||||
|
}
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
// 🔔 NOTIFICATIONS
|
// 🔔 NOTIFICATIONS
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -490,6 +490,42 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
|
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 🔍 SEARCH: Media, Files
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения с IMAGE вложениями (type: 0)
|
||||||
|
* Для вкладки "Media" в поиске
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account
|
||||||
|
AND attachments != '[]'
|
||||||
|
AND attachments LIKE '%"type":0%'
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getMessagesWithMedia(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить сообщения с FILE вложениями (type: 2)
|
||||||
|
* Для вкладки "Files" в поиске
|
||||||
|
*/
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT * FROM messages
|
||||||
|
WHERE account = :account
|
||||||
|
AND attachments != '[]'
|
||||||
|
AND attachments LIKE '%"type":2%'
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DAO для работы с диалогами */
|
/** DAO для работы с диалогами */
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class FileDownloadState(
|
||||||
|
val attachmentId: String,
|
||||||
|
val fileName: String,
|
||||||
|
val status: FileDownloadStatus,
|
||||||
|
/** 0f..1f */
|
||||||
|
val progress: Float = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class FileDownloadStatus {
|
||||||
|
QUEUED,
|
||||||
|
DOWNLOADING,
|
||||||
|
DECRYPTING,
|
||||||
|
DONE,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
object FileDownloadManager {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
/** Все активные/завершённые скачивания */
|
||||||
|
private val _downloads = MutableStateFlow<Map<String, FileDownloadState>>(emptyMap())
|
||||||
|
val downloads: StateFlow<Map<String, FileDownloadState>> = _downloads.asStateFlow()
|
||||||
|
|
||||||
|
/** Текущие Job'ы — чтобы не запускать повторно */
|
||||||
|
private val jobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
|
// ─── helpers ───
|
||||||
|
|
||||||
|
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||||
|
|
||||||
|
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
|
||||||
|
if (!isGroupStoredKey(storedKey)) return null
|
||||||
|
val encoded = storedKey.removePrefix("group:")
|
||||||
|
if (encoded.isBlank()) return null
|
||||||
|
return CryptoManager.decryptWithPassword(encoded, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBase64Payload(data: String): ByteArray? {
|
||||||
|
val raw = data.trim()
|
||||||
|
if (raw.isBlank()) return null
|
||||||
|
val payload =
|
||||||
|
if (raw.startsWith("data:") && raw.contains(",")) raw.substringAfter(",")
|
||||||
|
else raw
|
||||||
|
return try {
|
||||||
|
android.util.Base64.decode(payload, android.util.Base64.DEFAULT)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── public API ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, идёт ли уже скачивание этого attachment
|
||||||
|
*/
|
||||||
|
fun isDownloading(attachmentId: String): Boolean {
|
||||||
|
val state = _downloads.value[attachmentId] ?: return false
|
||||||
|
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
||||||
|
*/
|
||||||
|
fun progressOf(attachmentId: String): Flow<FileDownloadState?> =
|
||||||
|
_downloads.map { it[attachmentId] }.distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает скачивание файла. Если уже скачивается — игнорирует.
|
||||||
|
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||||
|
*/
|
||||||
|
fun download(
|
||||||
|
context: Context,
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
fileName: String,
|
||||||
|
savedFile: File
|
||||||
|
) {
|
||||||
|
// Уже в процессе?
|
||||||
|
if (jobs[attachmentId]?.isActive == true) return
|
||||||
|
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f)
|
||||||
|
|
||||||
|
jobs[attachmentId] = scope.launch {
|
||||||
|
try {
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
|
||||||
|
|
||||||
|
// Запускаем polling прогресса из TransportManager
|
||||||
|
val progressJob = launch {
|
||||||
|
TransportManager.downloading.collect { list ->
|
||||||
|
val entry = list.find { it.id == attachmentId }
|
||||||
|
if (entry != null) {
|
||||||
|
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
||||||
|
val p = (entry.progress / 100f) * 0.8f
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val success = withContext(Dispatchers.IO) {
|
||||||
|
if (isGroupStoredKey(chachaKey)) {
|
||||||
|
downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
|
||||||
|
} else {
|
||||||
|
downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressJob.cancel()
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
|
||||||
|
} else {
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||||
|
} catch (_: OutOfMemoryError) {
|
||||||
|
System.gc()
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||||
|
} finally {
|
||||||
|
jobs.remove(attachmentId)
|
||||||
|
// Автоочистка через 5 секунд после завершения
|
||||||
|
scope.launch {
|
||||||
|
delay(5000)
|
||||||
|
_downloads.update { it - attachmentId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет скачивание
|
||||||
|
*/
|
||||||
|
fun cancel(attachmentId: String) {
|
||||||
|
jobs[attachmentId]?.cancel()
|
||||||
|
jobs.remove(attachmentId)
|
||||||
|
_downloads.update { it - attachmentId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── internal download logic (moved from FileAttachment) ───
|
||||||
|
|
||||||
|
private suspend fun downloadGroupFile(
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
fileName: String,
|
||||||
|
savedFile: File
|
||||||
|
): Boolean {
|
||||||
|
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f)
|
||||||
|
|
||||||
|
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||||
|
if (groupPassword.isNullOrBlank()) return false
|
||||||
|
|
||||||
|
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
|
||||||
|
|
||||||
|
val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f)
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
savedFile.parentFile?.mkdirs()
|
||||||
|
savedFile.writeBytes(bytes)
|
||||||
|
}
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadDirectFile(
|
||||||
|
attachmentId: String,
|
||||||
|
downloadTag: String,
|
||||||
|
chachaKey: String,
|
||||||
|
privateKey: String,
|
||||||
|
fileName: String,
|
||||||
|
savedFile: File
|
||||||
|
): Boolean {
|
||||||
|
// Streaming: скачиваем во temp file
|
||||||
|
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f)
|
||||||
|
|
||||||
|
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
|
||||||
|
|
||||||
|
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MessageCrypto.decryptAttachmentFileStreaming(
|
||||||
|
tempFile,
|
||||||
|
decryptedKeyAndNonce,
|
||||||
|
savedFile
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) {
|
||||||
|
_downloads.update { map ->
|
||||||
|
map + (id to FileDownloadState(id, fileName, status, progress))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import androidx.compose.animation.scaleOut
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
@@ -91,6 +93,7 @@ import com.rosetta.messenger.ui.chats.utils.*
|
|||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import com.rosetta.messenger.ui.settings.ThemeWallpapers
|
||||||
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
|
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
|
||||||
import com.rosetta.messenger.utils.MediaUtils
|
import com.rosetta.messenger.utils.MediaUtils
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
|
|||||||
currentUserName: String = "",
|
currentUserName: String = "",
|
||||||
totalUnreadFromOthers: Int = 0,
|
totalUnreadFromOthers: Int = 0,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
chatWallpaperId: String = "",
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onImageViewerChanged: (Boolean) -> Unit = {}
|
onImageViewerChanged: (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
// UI Theme
|
// UI Theme
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
|
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
val headerIconColor = Color.White
|
val headerIconColor = Color.White
|
||||||
@@ -1814,14 +1819,29 @@ fun ChatDetailScreen(
|
|||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
|
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// 🔥 Column структура - список сжимается когда клавиатура
|
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||||
// открывается
|
// when content paddings (bottom bar/IME) change.
|
||||||
Column(
|
if (chatWallpaperResId != null) {
|
||||||
modifier =
|
Image(
|
||||||
Modifier.fillMaxSize()
|
painter = painterResource(id = chatWallpaperResId),
|
||||||
.padding(paddingValues)
|
contentDescription = "Chat wallpaper",
|
||||||
.background(backgroundColor)
|
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()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
// Плавная анимация bottom padding при входе/выходе из selection mode
|
// Плавная анимация bottom padding при входе/выходе из selection mode
|
||||||
@@ -2495,27 +2515,6 @@ fun ChatDetailScreen(
|
|||||||
} // Закрытие Box wrapper для Scaffold content
|
} // Закрытие Box wrapper для Scaffold content
|
||||||
} // Закрытие Box
|
} // Закрытие Box
|
||||||
|
|
||||||
// 📸 Image Viewer Overlay with Telegram-style shared element animation
|
|
||||||
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
|
||||||
ImageViewerScreen(
|
|
||||||
images = imageViewerImages,
|
|
||||||
initialIndex = imageViewerInitialIndex,
|
|
||||||
privateKey = currentUserPrivateKey,
|
|
||||||
onDismiss = {
|
|
||||||
showImageViewer = false
|
|
||||||
imageViewerSourceBounds = null
|
|
||||||
imageViewerImages = emptyList()
|
|
||||||
onImageViewerChanged(false)
|
|
||||||
},
|
|
||||||
onClosingStart = {
|
|
||||||
// Сразу сбрасываем status bar при начале закрытия (до анимации)
|
|
||||||
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
sourceBounds = imageViewerSourceBounds
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Диалог подтверждения удаления чата
|
// Диалог подтверждения удаления чата
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
|
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
|
||||||
@@ -2773,7 +2772,30 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📷 In-App Camera (без системного превью!)
|
} // Закрытие Scaffold content lambda
|
||||||
|
|
||||||
|
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
|
||||||
|
if (showImageViewer && imageViewerImages.isNotEmpty()) {
|
||||||
|
ImageViewerScreen(
|
||||||
|
images = imageViewerImages,
|
||||||
|
initialIndex = imageViewerInitialIndex,
|
||||||
|
privateKey = currentUserPrivateKey,
|
||||||
|
onDismiss = {
|
||||||
|
showImageViewer = false
|
||||||
|
imageViewerSourceBounds = null
|
||||||
|
imageViewerImages = emptyList()
|
||||||
|
onImageViewerChanged(false)
|
||||||
|
},
|
||||||
|
onClosingStart = {
|
||||||
|
// Сразу сбрасываем status bar при начале закрытия (до анимации)
|
||||||
|
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
sourceBounds = imageViewerSourceBounds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
|
||||||
if (showInAppCamera) {
|
if (showInAppCamera) {
|
||||||
InAppCameraScreen(
|
InAppCameraScreen(
|
||||||
onDismiss = { showInAppCamera = false },
|
onDismiss = { showInAppCamera = false },
|
||||||
@@ -2835,5 +2857,5 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
} // Закрытие outer Box
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,7 +442,11 @@ fun ChatsListScreen(
|
|||||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||||
|
|
||||||
// 🔥 Пользователи, которые сейчас печатают
|
// <EFBFBD> Active downloads tracking (for header indicator)
|
||||||
|
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
|
||||||
|
val hasActiveDownloads = activeDownloads.isNotEmpty()
|
||||||
|
|
||||||
|
// <20>🔥 Пользователи, которые сейчас печатают
|
||||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||||
|
|
||||||
// Load dialogs when account is available
|
// Load dialogs when account is available
|
||||||
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!showRequestsScreen) {
|
if (!showRequestsScreen) {
|
||||||
|
// 📥 Animated download indicator (Telegram-style)
|
||||||
|
Box(
|
||||||
|
modifier = androidx.compose.ui.Modifier.size(48.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
|
||||||
|
isActive = hasActiveDownloads,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (protocolState ==
|
if (protocolState ==
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
|
|||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen connection logs viewer.
|
|
||||||
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionLogsScreen(
|
fun ConnectionLogsScreen(
|
||||||
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Auto-scroll to bottom when new logs arrive
|
|
||||||
LaunchedEffect(logs.size) {
|
LaunchedEffect(logs.size) {
|
||||||
if (logs.isNotEmpty()) {
|
if (logs.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(logs.size - 1)
|
listState.animateScrollToItem(logs.size - 1)
|
||||||
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
|
|||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
) {
|
) {
|
||||||
// Header
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Clear button
|
|
||||||
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = TablerIcons.Trash,
|
imageVector = TablerIcons.Trash,
|
||||||
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
||||||
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status bar
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs list
|
|
||||||
if (logs.isEmpty()) {
|
if (logs.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -25,10 +28,13 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
@@ -79,6 +85,7 @@ import androidx.compose.ui.graphics.toArgb
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
@@ -214,8 +221,6 @@ fun GroupInfoScreen(
|
|||||||
onGroupLeft: () -> Unit = {},
|
onGroupLeft: () -> Unit = {},
|
||||||
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
|
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
@@ -276,7 +281,7 @@ fun GroupInfoScreen(
|
|||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
var showLeaveConfirm by remember { mutableStateOf(false) }
|
var showLeaveConfirm by remember { mutableStateOf(false) }
|
||||||
var isLeaving by remember { mutableStateOf(false) }
|
var isLeaving by remember { mutableStateOf(false) }
|
||||||
var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||||
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||||
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||||
var membersLoading by remember { mutableStateOf(false) }
|
var membersLoading by remember { mutableStateOf(false) }
|
||||||
@@ -451,9 +456,23 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val onlineCount by remember(members, memberInfoByKey) {
|
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
||||||
|
|
||||||
|
val onlineCount by remember(members, memberInfoByKey, normalizedCurrentUserKey) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
members.count { key -> (memberInfoByKey[key]?.online ?: 0) > 0 }
|
if (members.isEmpty()) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
val selfOnline = if (normalizedCurrentUserKey.isNotBlank()) 1 else 0
|
||||||
|
val othersOnline =
|
||||||
|
members.count { key ->
|
||||||
|
val isCurrentUser =
|
||||||
|
normalizedCurrentUserKey.isNotBlank() &&
|
||||||
|
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
|
||||||
|
!isCurrentUser && (memberInfoByKey[key]?.online ?: 0) > 0
|
||||||
|
}
|
||||||
|
selfOnline + othersOnline
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +517,6 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
|
||||||
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
|
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
||||||
@@ -508,6 +526,14 @@ fun GroupInfoScreen(
|
|||||||
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BackHandler {
|
||||||
|
if (showEncryptionPage) {
|
||||||
|
showEncryptionPage = false
|
||||||
|
} else {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (selectedTab != GroupInfoTab.MEMBERS) {
|
if (selectedTab != GroupInfoTab.MEMBERS) {
|
||||||
swipedMemberKey = null
|
swipedMemberKey = null
|
||||||
@@ -684,7 +710,7 @@ fun GroupInfoScreen(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
encryptionKey = key
|
encryptionKey = key
|
||||||
showEncryptionDialog = true
|
showEncryptionPage = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,103 +1220,26 @@ 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) }
|
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
|
||||||
val keyImagePalette = if (isDarkTheme) {
|
GroupEncryptionKeyPage(
|
||||||
listOf(
|
encryptionKey = encryptionKey,
|
||||||
Color(0xFF2B4F78),
|
displayLines = displayLines,
|
||||||
Color(0xFF2F5F90),
|
peerTitle = groupTitle,
|
||||||
Color(0xFF3D74A8),
|
isDarkTheme = isDarkTheme,
|
||||||
Color(0xFF4E89BE),
|
topSurfaceColor = topSurfaceColor,
|
||||||
Color(0xFF64A0D6)
|
backgroundColor = backgroundColor,
|
||||||
)
|
secondaryText = secondaryText,
|
||||||
} else {
|
accentColor = accentColor,
|
||||||
listOf(
|
onBack = { showEncryptionPage = false },
|
||||||
Color(0xFFD5E8FF),
|
onCopy = {
|
||||||
Color(0xFFBBD9FF),
|
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||||
Color(0xFFA1CAFF),
|
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||||
Color(0xFF87BAFF),
|
|
||||||
Color(0xFF6EA9F4)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val keyCardColor = if (isDarkTheme) Color(0xFF1F1F22) else Color(0xFFF7F9FC)
|
|
||||||
val keyCodeColor = if (isDarkTheme) Color(0xFFC7D6EA) else Color(0xFF34495E)
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showEncryptionDialog = false },
|
|
||||||
containerColor = cardColor,
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Encryption key",
|
|
||||||
color = primaryText,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = keyCardColor,
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 14.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
DesktopStyleKeyImage(
|
|
||||||
keyRender = encryptionKey,
|
|
||||||
size = 180.dp,
|
|
||||||
radius = 14.dp,
|
|
||||||
palette = keyImagePalette
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
SelectionContainer {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = sectionColor,
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
|
||||||
if (displayLines.isNotEmpty()) {
|
|
||||||
displayLines.forEach { line ->
|
|
||||||
Text(
|
|
||||||
text = line,
|
|
||||||
color = keyCodeColor,
|
|
||||||
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 = primaryText)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1401,6 +1350,162 @@ private fun DesktopStyleKeyImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupEncryptionKeyPage(
|
||||||
|
encryptionKey: String,
|
||||||
|
displayLines: List<String>,
|
||||||
|
peerTitle: String,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
topSurfaceColor: Color,
|
||||||
|
backgroundColor: Color,
|
||||||
|
secondaryText: Color,
|
||||||
|
accentColor: Color,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onCopy: () -> Unit
|
||||||
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val imageSize = (screenWidth - 80.dp).coerceIn(220.dp, 340.dp)
|
||||||
|
val keyImagePalette = if (isDarkTheme) {
|
||||||
|
listOf(
|
||||||
|
Color(0xFF2B4F78),
|
||||||
|
Color(0xFF2F5F90),
|
||||||
|
Color(0xFF3D74A8),
|
||||||
|
Color(0xFF4E89BE),
|
||||||
|
Color(0xFF64A0D6)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
Color(0xFFD5E8FF),
|
||||||
|
Color(0xFFBBD9FF),
|
||||||
|
Color(0xFFA1CAFF),
|
||||||
|
Color(0xFF87BAFF),
|
||||||
|
Color(0xFF6EA9F4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val keyPanelColor = if (isDarkTheme) Color(0xFF202227) else Color(0xFFFFFFFF)
|
||||||
|
val keyCodeColor = if (isDarkTheme) Color(0xFFC7D6EA) else Color(0xFF34495E)
|
||||||
|
val detailsPanelColor = if (isDarkTheme) Color(0xFF1B1D22) else Color(0xFFF7F9FC)
|
||||||
|
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
) {
|
||||||
|
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 = 22.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
TextButton(onClick = onCopy) {
|
||||||
|
Text(
|
||||||
|
text = "Copy",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.widthIn(max = 420.dp),
|
||||||
|
color = keyPanelColor,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
DesktopStyleKeyImage(
|
||||||
|
keyRender = encryptionKey,
|
||||||
|
size = imageSize,
|
||||||
|
radius = 0.dp,
|
||||||
|
palette = keyImagePalette
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = detailsPanelColor,
|
||||||
|
shape = RoundedCornerShape(14.dp)
|
||||||
|
) {
|
||||||
|
SelectionContainer {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||||
|
displayLines.forEach { line ->
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
color = keyCodeColor,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.",
|
||||||
|
color = secondaryText,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = 21.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "If they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
|
||||||
|
color = secondaryText,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = 21.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
TextButton(onClick = { uriHandler.openUri("https://rosetta.im/") }) {
|
||||||
|
Text(
|
||||||
|
text = "Learn more at rosetta.im",
|
||||||
|
color = accentColor,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GroupActionButton(
|
private fun GroupActionButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|||||||
@@ -226,11 +226,13 @@ fun GroupSetupScreen(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val imeBottomPx = WindowInsets.ime.getBottom(density)
|
val imeBottomPx = WindowInsets.ime.getBottom(density)
|
||||||
val imeBottomDp = with(density) { imeBottomPx.toDp() }
|
val imeBottomDp = with(density) { imeBottomPx.toDp() }
|
||||||
val keyboardOrEmojiHeight =
|
|
||||||
if (coordinator.isEmojiBoxVisible) coordinator.emojiHeight else imeBottomDp
|
|
||||||
val fabBottomPadding =
|
val fabBottomPadding =
|
||||||
if (keyboardOrEmojiHeight > 0.dp) {
|
if (coordinator.isEmojiBoxVisible) {
|
||||||
keyboardOrEmojiHeight + 14.dp
|
// Emoji panel height is already reserved by Scaffold bottomBar.
|
||||||
|
14.dp
|
||||||
|
} else if (imeBottomDp > 0.dp) {
|
||||||
|
// System keyboard is not part of Scaffold content padding.
|
||||||
|
imeBottomDp + 14.dp
|
||||||
} else {
|
} else {
|
||||||
18.dp
|
18.dp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1433,7 +1433,6 @@ fun FileAttachment(
|
|||||||
messageStatus: MessageStatus = MessageStatus.READ
|
messageStatus: MessageStatus = MessageStatus.READ
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
|
||||||
var downloadProgress by remember { mutableStateOf(0f) }
|
var downloadProgress by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
@@ -1465,7 +1464,30 @@ fun FileAttachment(
|
|||||||
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
|
||||||
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
|
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
|
||||||
|
|
||||||
|
// 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
|
||||||
|
val managerState by com.rosetta.messenger.network.FileDownloadManager
|
||||||
|
.progressOf(attachment.id)
|
||||||
|
.collectAsState(initial = null)
|
||||||
|
|
||||||
|
// Синхронизируем локальный UI с глобальным менеджером
|
||||||
|
LaunchedEffect(managerState) {
|
||||||
|
val state = managerState ?: return@LaunchedEffect
|
||||||
|
downloadProgress = state.progress
|
||||||
|
downloadStatus = when (state.status) {
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
|
||||||
|
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(attachment.id) {
|
LaunchedEffect(attachment.id) {
|
||||||
|
// Если менеджер уже качает этот файл — подхватим состояние оттуда
|
||||||
|
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
|
||||||
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
downloadStatus = if (isDownloadTag(preview)) {
|
downloadStatus = if (isDownloadTag(preview)) {
|
||||||
// Проверяем, был ли файл уже скачан ранее
|
// Проверяем, был ли файл уже скачан ранее
|
||||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
@@ -1507,76 +1529,20 @@ fun FileAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📥 Запуск скачивания через глобальный FileDownloadManager
|
||||||
val download: () -> Unit = {
|
val download: () -> Unit = {
|
||||||
if (downloadTag.isNotEmpty()) {
|
if (downloadTag.isNotEmpty()) {
|
||||||
scope.launch {
|
downloadStatus = DownloadStatus.DOWNLOADING
|
||||||
try {
|
downloadProgress = 0f
|
||||||
downloadStatus = DownloadStatus.DOWNLOADING
|
com.rosetta.messenger.network.FileDownloadManager.download(
|
||||||
|
context = context,
|
||||||
// Streaming: скачиваем во temp file, не в память
|
attachmentId = attachment.id,
|
||||||
val success =
|
downloadTag = downloadTag,
|
||||||
if (isGroupStoredKey(chachaKey)) {
|
chachaKey = chachaKey,
|
||||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
privateKey = privateKey,
|
||||||
downloadProgress = 0.5f
|
fileName = fileName,
|
||||||
downloadStatus = DownloadStatus.DECRYPTING
|
savedFile = savedFile
|
||||||
|
)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1623,7 +1589,9 @@ fun FileAttachment(
|
|||||||
) {
|
) {
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
|
||||||
|
// Determinate progress like Telegram
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
|
progress = downloadProgress.coerceIn(0f, 1f),
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
@@ -1693,10 +1661,14 @@ fun FileAttachment(
|
|||||||
|
|
||||||
when (downloadStatus) {
|
when (downloadStatus) {
|
||||||
DownloadStatus.DOWNLOADING -> {
|
DownloadStatus.DOWNLOADING -> {
|
||||||
AnimatedDotsText(
|
// Telegram-style: "1.2 MB / 5.4 MB"
|
||||||
baseText = "Downloading",
|
// CDN download maps to progress 0..0.8
|
||||||
color = statusColor,
|
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
|
||||||
fontSize = 12.sp
|
val downloadedBytes = (cdnFraction * fileSize).toLong()
|
||||||
|
Text(
|
||||||
|
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = statusColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DownloadStatus.DECRYPTING -> {
|
DownloadStatus.DECRYPTING -> {
|
||||||
|
|||||||
@@ -592,11 +592,19 @@ fun MessageBubble(
|
|||||||
.IMAGE
|
.IMAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isStandaloneGroupInvite =
|
||||||
|
message.attachments.isEmpty() &&
|
||||||
|
message.replyData == null &&
|
||||||
|
message.forwardedMessages.isEmpty() &&
|
||||||
|
message.text.isNotBlank() &&
|
||||||
|
isGroupInviteCode(message.text)
|
||||||
|
|
||||||
// Для сообщений только с фото - минимальный padding и тонкий border
|
// Для сообщений только с фото - минимальный padding и тонкий border
|
||||||
// Для фото + caption - padding только внизу для текста
|
// Для фото + caption - padding только внизу для текста
|
||||||
val bubblePadding =
|
val bubblePadding =
|
||||||
when {
|
when {
|
||||||
isSafeSystemMessage -> PaddingValues(0.dp)
|
isSafeSystemMessage -> PaddingValues(0.dp)
|
||||||
|
isStandaloneGroupInvite -> PaddingValues(0.dp)
|
||||||
hasOnlyMedia -> PaddingValues(0.dp)
|
hasOnlyMedia -> PaddingValues(0.dp)
|
||||||
hasImageWithCaption -> PaddingValues(0.dp)
|
hasImageWithCaption -> PaddingValues(0.dp)
|
||||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||||
@@ -676,6 +684,8 @@ fun MessageBubble(
|
|||||||
val bubbleWidthModifier =
|
val bubbleWidthModifier =
|
||||||
if (isSafeSystemMessage) {
|
if (isSafeSystemMessage) {
|
||||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||||
|
} else if (isStandaloneGroupInvite) {
|
||||||
|
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||||
Modifier.width(
|
Modifier.width(
|
||||||
photoWidth
|
photoWidth
|
||||||
@@ -703,46 +713,52 @@ fun MessageBubble(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick
|
onLongClick = onLongClick
|
||||||
)
|
)
|
||||||
.clip(bubbleShape)
|
|
||||||
.then(
|
.then(
|
||||||
if (hasOnlyMedia) {
|
if (isStandaloneGroupInvite) {
|
||||||
Modifier.border(
|
Modifier
|
||||||
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 {
|
} 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) {
|
if (isSafeSystemMessage) {
|
||||||
SafeSystemMessageCard(
|
SafeSystemMessageCard(
|
||||||
@@ -1045,35 +1061,12 @@ fun MessageBubble(
|
|||||||
accountPublicKey = currentUserPublicKey,
|
accountPublicKey = currentUserPublicKey,
|
||||||
accountPrivateKey = privateKey,
|
accountPrivateKey = privateKey,
|
||||||
actionsEnabled = !isSelectionMode,
|
actionsEnabled = !isSelectionMode,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
messageStatus = displayStatus,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete,
|
||||||
onOpenGroup = onGroupInviteOpen
|
onOpenGroup = onGroupInviteOpen
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = timeFormat.format(message.timestamp),
|
|
||||||
color = timeColor,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
|
||||||
)
|
|
||||||
if (message.isOutgoing) {
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
AnimatedMessageStatus(
|
|
||||||
status = displayStatus,
|
|
||||||
timeColor = statusColor,
|
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
isOutgoing = message.isOutgoing,
|
|
||||||
timestamp = message.timestamp.time,
|
|
||||||
onRetry = onRetry,
|
|
||||||
onDelete = onDelete
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Telegram-style: текст + время с автоматическим
|
// Telegram-style: текст + время с автоматическим
|
||||||
// переносом
|
// переносом
|
||||||
@@ -1275,6 +1268,10 @@ private fun GroupInviteInlineCard(
|
|||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
accountPrivateKey: String,
|
accountPrivateKey: String,
|
||||||
actionsEnabled: Boolean,
|
actionsEnabled: Boolean,
|
||||||
|
timestamp: Date,
|
||||||
|
messageStatus: MessageStatus,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
onOpenGroup: (SearchUser) -> Unit
|
onOpenGroup: (SearchUser) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -1352,19 +1349,19 @@ private fun GroupInviteInlineCard(
|
|||||||
|
|
||||||
val cardBackground =
|
val cardBackground =
|
||||||
if (isOutgoing) {
|
if (isOutgoing) {
|
||||||
Color.White.copy(alpha = 0.16f)
|
PrimaryBlue
|
||||||
} else if (isDarkTheme) {
|
} else if (isDarkTheme) {
|
||||||
Color.White.copy(alpha = 0.06f)
|
Color(0xFF222326)
|
||||||
} else {
|
} else {
|
||||||
Color.Black.copy(alpha = 0.03f)
|
Color(0xFFF5F7FA)
|
||||||
}
|
}
|
||||||
val cardBorder =
|
val cardBorder =
|
||||||
if (isOutgoing) {
|
if (isOutgoing) {
|
||||||
Color.White.copy(alpha = 0.22f)
|
Color.White.copy(alpha = 0.24f)
|
||||||
} else if (isDarkTheme) {
|
} else if (isDarkTheme) {
|
||||||
Color.White.copy(alpha = 0.12f)
|
Color.White.copy(alpha = 0.1f)
|
||||||
} else {
|
} else {
|
||||||
Color.Black.copy(alpha = 0.08f)
|
Color.Black.copy(alpha = 0.07f)
|
||||||
}
|
}
|
||||||
val titleColor =
|
val titleColor =
|
||||||
if (isOutgoing) Color.White
|
if (isOutgoing) Color.White
|
||||||
@@ -1374,6 +1371,12 @@ private fun GroupInviteInlineCard(
|
|||||||
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
||||||
else if (isDarkTheme) Color(0xFFA9AFBA)
|
else if (isDarkTheme) Color(0xFFA9AFBA)
|
||||||
else Color(0xFF70757F)
|
else Color(0xFF70757F)
|
||||||
|
val timeColor =
|
||||||
|
if (isOutgoing) Color.White.copy(alpha = 0.74f)
|
||||||
|
else if (isDarkTheme) Color(0xFF8E8E93)
|
||||||
|
else Color(0xFF666666)
|
||||||
|
val statusColor = if (isOutgoing) Color.White else timeColor
|
||||||
|
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||||
|
|
||||||
val accentColor =
|
val accentColor =
|
||||||
when (status) {
|
when (status) {
|
||||||
@@ -1458,91 +1461,121 @@ private fun GroupInviteInlineCard(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = cardBackground,
|
color = cardBackground,
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier =
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Modifier.size(34.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
imageVector = Icons.Default.Link,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
color = titleColor,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
color = subtitleColor,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
Modifier.size(34.dp)
|
||||||
enabled = actionEnabled,
|
.clip(CircleShape)
|
||||||
onClick = ::handleAction
|
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
||||||
),
|
contentAlignment = Alignment.Center
|
||||||
color =
|
|
||||||
if (isOutgoing) {
|
|
||||||
Color.White.copy(alpha = 0.2f)
|
|
||||||
} else {
|
|
||||||
accentColor.copy(alpha = 0.14f)
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Icon(
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
imageVector = Icons.Default.Link,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = titleColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
color = subtitleColor,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
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)
|
||||||
) {
|
) {
|
||||||
if (actionLoading || statusLoading) {
|
Row(
|
||||||
CircularProgressIndicator(
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
modifier = Modifier.size(12.dp),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
strokeWidth = 1.8.dp,
|
) {
|
||||||
color = accentColor
|
if (actionLoading || statusLoading) {
|
||||||
)
|
CircularProgressIndicator(
|
||||||
} else {
|
modifier = Modifier.size(12.dp),
|
||||||
Icon(
|
strokeWidth = 1.8.dp,
|
||||||
imageVector = actionIcon,
|
color = accentColor
|
||||||
contentDescription = null,
|
)
|
||||||
tint = accentColor,
|
} else {
|
||||||
modifier = Modifier.size(12.dp)
|
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.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = actionLabel,
|
|
||||||
color = accentColor,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = timeFormat.format(timestamp),
|
||||||
|
color = timeColor,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||||
|
)
|
||||||
|
if (isOutgoing) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
AnimatedMessageStatus(
|
||||||
|
status = messageStatus,
|
||||||
|
timeColor = statusColor,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = timestamp.time,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -268,12 +268,15 @@ fun SwipeBackContainer(
|
|||||||
var totalDragY = 0f
|
var totalDragY = 0f
|
||||||
var passedSlop = false
|
var passedSlop = false
|
||||||
|
|
||||||
// Use Initial pass to intercept BEFORE children
|
// Pre-slop: use Main pass so children (e.g. LazyRow)
|
||||||
|
// process first — if they consume, we back off.
|
||||||
|
// Post-claim: use Initial pass to intercept before children.
|
||||||
while (true) {
|
while (true) {
|
||||||
val event =
|
val pass =
|
||||||
awaitPointerEvent(
|
if (startedSwipe)
|
||||||
PointerEventPass.Initial
|
PointerEventPass.Initial
|
||||||
)
|
else PointerEventPass.Main
|
||||||
|
val event = awaitPointerEvent(pass)
|
||||||
val change =
|
val change =
|
||||||
event.changes.firstOrNull {
|
event.changes.firstOrNull {
|
||||||
it.id == down.id
|
it.id == down.id
|
||||||
@@ -289,6 +292,9 @@ fun SwipeBackContainer(
|
|||||||
totalDragY += dragDelta.y
|
totalDragY += dragDelta.y
|
||||||
|
|
||||||
if (!passedSlop) {
|
if (!passedSlop) {
|
||||||
|
// Child (e.g. LazyRow) already consumed — let it handle
|
||||||
|
if (change.isConsumed) break
|
||||||
|
|
||||||
val totalDistance =
|
val totalDistance =
|
||||||
kotlin.math.sqrt(
|
kotlin.math.sqrt(
|
||||||
totalDragX *
|
totalDragX *
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import androidx.compose.animation.core.Animatable
|
|||||||
import androidx.compose.animation.core.CubicBezierEasing
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -18,7 +21,6 @@ import androidx.compose.material3.*
|
|||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -37,7 +39,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
@@ -64,8 +68,10 @@ private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
|
|||||||
fun ThemeScreen(
|
fun ThemeScreen(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
currentThemeMode: String,
|
currentThemeMode: String,
|
||||||
|
currentWallpaperId: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onThemeModeChange: (String) -> Unit
|
onThemeModeChange: (String) -> Unit,
|
||||||
|
onWallpaperChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
@@ -78,7 +84,6 @@ fun ThemeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -95,11 +100,16 @@ fun ThemeScreen(
|
|||||||
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
var wallpaperId by remember { mutableStateOf(currentWallpaperId) }
|
||||||
|
|
||||||
LaunchedEffect(currentThemeMode) {
|
LaunchedEffect(currentThemeMode) {
|
||||||
themeMode = currentThemeMode
|
themeMode = currentThemeMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(currentWallpaperId) {
|
||||||
|
wallpaperId = currentWallpaperId
|
||||||
|
}
|
||||||
|
|
||||||
fun resolveThemeIsDark(mode: String): Boolean =
|
fun resolveThemeIsDark(mode: String): Boolean =
|
||||||
when (mode) {
|
when (mode) {
|
||||||
"dark" -> true
|
"dark" -> true
|
||||||
@@ -214,7 +224,7 @@ fun ThemeScreen(
|
|||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
// CHAT PREVIEW - Message bubbles like in real chat
|
// CHAT PREVIEW - Message bubbles like in real chat
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
ChatPreview(isDarkTheme = isDarkTheme)
|
ChatPreview(isDarkTheme = isDarkTheme, wallpaperId = wallpaperId)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -266,7 +276,27 @@ fun ThemeScreen(
|
|||||||
secondaryTextColor = secondaryTextColor
|
secondaryTextColor = secondaryTextColor
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
TelegramSectionHeader("Chat Wallpaper", secondaryTextColor)
|
||||||
|
|
||||||
|
WallpaperSelectorRow(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
selectedWallpaperId = wallpaperId,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onWallpaperSelected = { selectedId ->
|
||||||
|
if (selectedId != wallpaperId) {
|
||||||
|
wallpaperId = selectedId
|
||||||
|
onWallpaperChange(selectedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
TelegramInfoText(
|
||||||
|
text = "Selected wallpaper is used for chat backgrounds.",
|
||||||
|
secondaryTextColor = secondaryTextColor
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,16 +450,111 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WallpaperSelectorRow(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
selectedWallpaperId: String,
|
||||||
|
textColor: Color,
|
||||||
|
secondaryTextColor: Color,
|
||||||
|
onWallpaperSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item(key = "none") {
|
||||||
|
WallpaperSelectorItem(
|
||||||
|
title = "No wallpaper",
|
||||||
|
wallpaperResId = null,
|
||||||
|
isSelected = selectedWallpaperId.isBlank(),
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onClick = { onWallpaperSelected("") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
|
||||||
|
WallpaperSelectorItem(
|
||||||
|
title = wallpaper.name,
|
||||||
|
wallpaperResId = wallpaper.drawableRes,
|
||||||
|
isSelected = wallpaper.id == selectedWallpaperId,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
textColor = textColor,
|
||||||
|
secondaryTextColor = secondaryTextColor,
|
||||||
|
onClick = { onWallpaperSelected(wallpaper.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WallpaperSelectorItem(
|
||||||
|
title: String,
|
||||||
|
wallpaperResId: Int?,
|
||||||
|
isSelected: Boolean,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
textColor: Color,
|
||||||
|
secondaryTextColor: Color,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val borderColor = if (isSelected) Color(0xFF007AFF) else secondaryTextColor.copy(alpha = 0.35f)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier.width(118.dp).clickable(onClick = onClick),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(76.dp),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor)
|
||||||
|
) {
|
||||||
|
if (wallpaperResId != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = wallpaperResId),
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (isSelected) textColor else secondaryTextColor,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// 💬 CHAT PREVIEW - Real message bubbles preview
|
// 💬 CHAT PREVIEW - Real message bubbles preview
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatPreview(isDarkTheme: Boolean) {
|
private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
|
||||||
|
val wallpaperResId = remember(wallpaperId) { ThemeWallpapers.drawableResOrNull(wallpaperId) }
|
||||||
|
val hasWallpaper = wallpaperResId != null
|
||||||
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
|
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
|
||||||
// Message colors matching real ChatDetailScreen
|
// Message colors matching real ChatDetailScreen
|
||||||
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
|
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
|
||||||
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
val otherBubbleColor =
|
||||||
|
if (hasWallpaper) {
|
||||||
|
if (isDarkTheme) Color(0xFF2C2E33) else Color.White
|
||||||
|
} else {
|
||||||
|
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||||
|
}
|
||||||
val myTextColor = Color.White // White text on blue bubble
|
val myTextColor = Color.White // White text on blue bubble
|
||||||
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
|
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
|
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
|
||||||
@@ -444,56 +569,65 @@ private fun ChatPreview(isDarkTheme: Boolean) {
|
|||||||
color = chatBgColor,
|
color = chatBgColor,
|
||||||
shape = RoundedCornerShape(16.dp)
|
shape = RoundedCornerShape(16.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
if (wallpaperResId != null) {
|
||||||
.fillMaxSize()
|
Image(
|
||||||
.padding(12.dp),
|
painter = painterResource(id = wallpaperResId),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
contentDescription = "Chat wallpaper preview",
|
||||||
) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Incoming message
|
contentScale = ContentScale.Crop
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing message
|
Column(
|
||||||
Row(
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
horizontalArrangement = Arrangement.End
|
|
||||||
) {
|
) {
|
||||||
MessageBubble(
|
// Incoming message
|
||||||
text = "Hey! All good, just checking out this new theme 😊",
|
Row(
|
||||||
time = "10:43",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
isMe = true,
|
horizontalArrangement = Arrangement.Start
|
||||||
bubbleColor = myBubbleColor,
|
) {
|
||||||
textColor = myTextColor,
|
MessageBubble(
|
||||||
timeColor = myTimeColor,
|
text = "Hey! How's it going? 👋",
|
||||||
checkmarkColor = myCheckColor
|
time = "10:42",
|
||||||
)
|
isMe = false,
|
||||||
}
|
bubbleColor = otherBubbleColor,
|
||||||
|
textColor = otherTextColor,
|
||||||
// Incoming message
|
timeColor = otherTimeColor
|
||||||
Row(
|
)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
horizontalArrangement = Arrangement.Start
|
|
||||||
) {
|
// Outgoing message
|
||||||
MessageBubble(
|
Row(
|
||||||
text = "Nice! Looks great! 🔥",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
time = "10:44",
|
horizontalArrangement = Arrangement.End
|
||||||
isMe = false,
|
) {
|
||||||
bubbleColor = otherBubbleColor,
|
MessageBubble(
|
||||||
textColor = otherTextColor,
|
text = "Hey! All good, just checking out this new theme 😊",
|
||||||
timeColor = otherTimeColor
|
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 |