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.
This commit is contained in:
2026-03-02 23:40:44 +05:00
parent 16c48992a5
commit 8c7ac53506
47 changed files with 2169 additions and 559 deletions

View File

@@ -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 }
) )

View File

@@ -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
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

@@ -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 для работы с диалогами */

View File

@@ -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))
}
}
}

View File

@@ -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
} }

View File

@@ -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 ==

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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
} }

View File

@@ -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 -> {

View File

@@ -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
)
}
}
} }
} }
} }

View File

@@ -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
)
}
}
}
}

View File

@@ -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 *

View File

@@ -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
)
}
} }
} }
} }

View File

@@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B