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
.backgroundBlurColorIdForAccount(accountPublicKey)
.collectAsState(initial = "avatar")
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
// AvatarRepository для работы с аватарами
@@ -899,8 +900,12 @@ fun MainScreen(
ThemeScreen(
isDarkTheme = isDarkTheme,
currentThemeMode = themeMode,
currentWallpaperId = chatWallpaperId,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
onThemeModeChange = onThemeModeChange
onThemeModeChange = onThemeModeChange,
onWallpaperChange = { wallpaperId ->
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
}
)
}
@@ -989,6 +994,7 @@ fun MainScreen(
} + Screen.ChatDetail(forwardUser)
},
isDarkTheme = isDarkTheme,
chatWallpaperId = chatWallpaperId,
avatarRepository = avatarRepository,
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 IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
// Notifications
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
@@ -100,6 +101,13 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
}
val chatWallpaperId: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
suspend fun setChatWallpaperId(value: String) {
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
}
// ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS
// ═════════════════════════════════════════════════════════════

View File

@@ -490,6 +490,42 @@ interface MessageDao {
"""
)
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
// ═══════════════════════════════════════════════════════════
// 🔍 SEARCH: Media, Files
// ═══════════════════════════════════════════════════════════
/**
* Получить сообщения с IMAGE вложениями (type: 0)
* Для вкладки "Media" в поиске
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":0%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithMedia(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* Получить сообщения с FILE вложениями (type: 2)
* Для вкладки "Files" в поиске
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":2%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
}
/** DAO для работы с диалогами */

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.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -91,6 +93,7 @@ import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.ThemeWallpapers
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
currentUserName: String = "",
totalUnreadFromOthers: Int = 0,
isDarkTheme: Boolean,
chatWallpaperId: String = "",
avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {}
) {
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
// UI Theme
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val headerIconColor = Color.White
@@ -1814,14 +1819,29 @@ fun ChatDetailScreen(
) { paddingValues ->
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
Box(modifier = Modifier.fillMaxSize()) {
// 🔥 Column структура - список сжимается когда клавиатура
// открывается
Column(
modifier =
Modifier.fillMaxSize()
.padding(paddingValues)
.background(backgroundColor)
) {
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
// when content paddings (bottom bar/IME) change.
if (chatWallpaperResId != null) {
Image(
painter = painterResource(id = chatWallpaperResId),
contentDescription = "Chat wallpaper",
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.matchParentSize()
.background(backgroundColor)
)
}
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// 🔥 Column структура - список сжимается когда клавиатура
// открывается
Column(
modifier = Modifier.fillMaxSize()
) {
// Список сообщений - занимает всё доступное место
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Плавная анимация bottom padding при входе/выходе из selection mode
@@ -2495,27 +2515,6 @@ fun ChatDetailScreen(
} // Закрытие Box wrapper для Scaffold content
} // Закрытие Box
// 📸 Image Viewer Overlay with Telegram-style shared element animation
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(
images = imageViewerImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
imageViewerImages = emptyList()
onImageViewerChanged(false)
},
onClosingStart = {
// Сразу сбрасываем status bar при начале закрытия (до анимации)
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds
)
}
// Диалог подтверждения удаления чата
if (showDeleteConfirm) {
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
@@ -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) {
InAppCameraScreen(
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 pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают
// <EFBFBD> Active downloads tracking (for header indicator)
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
val hasActiveDownloads = activeDownloads.isNotEmpty()
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
// Load dialogs when account is available
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
},
actions = {
if (!showRequestsScreen) {
// 📥 Animated download indicator (Telegram-style)
Box(
modifier = androidx.compose.ui.Modifier.size(48.dp),
contentAlignment = Alignment.Center
) {
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
isActive = hasActiveDownloads,
color = Color.White
)
}
IconButton(
onClick = {
if (protocolState ==

View File

@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.launch
/**
* Full-screen connection logs viewer.
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionLogsScreen(
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
.background(bgColor)
.statusBarsPadding()
) {
// Header
Box(
modifier = Modifier
.fillMaxWidth()
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
modifier = Modifier.weight(1f)
)
// Clear button
IconButton(onClick = { ProtocolManager.clearLogs() }) {
Icon(
imageVector = TablerIcons.Trash,
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
)
}
// Scroll to bottom
IconButton(onClick = {
scope.launch {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
}
}
// Status bar
Row(
modifier = Modifier
.fillMaxWidth()
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
)
}
// Logs list
if (logs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),

View File

@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -25,10 +28,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -79,6 +85,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
@@ -214,8 +221,6 @@ fun GroupInfoScreen(
onGroupLeft: () -> Unit = {},
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
) {
BackHandler(onBack = onBack)
val context = androidx.compose.ui.platform.LocalContext.current
val view = LocalView.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
@@ -276,7 +281,7 @@ fun GroupInfoScreen(
var showMenu by remember { mutableStateOf(false) }
var showLeaveConfirm by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var encryptionKeyLoading by remember { mutableStateOf(false) }
var membersLoading by remember { mutableStateOf(false) }
@@ -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 {
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) {
derivedStateOf {
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
@@ -508,6 +526,14 @@ fun GroupInfoScreen(
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
BackHandler {
if (showEncryptionPage) {
showEncryptionPage = false
} else {
onBack()
}
}
LaunchedEffect(selectedTab) {
if (selectedTab != GroupInfoTab.MEMBERS) {
swipedMemberKey = null
@@ -684,7 +710,7 @@ fun GroupInfoScreen(
return@launch
}
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 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 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)
}
GroupEncryptionKeyPage(
encryptionKey = encryptionKey,
displayLines = displayLines,
peerTitle = groupTitle,
isDarkTheme = isDarkTheme,
topSurfaceColor = topSurfaceColor,
backgroundColor = backgroundColor,
secondaryText = secondaryText,
accentColor = accentColor,
onBack = { showEncryptionPage = false },
onCopy = {
clipboardManager.setText(AnnotatedString(encryptionKey))
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
}
)
}
@@ -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
private fun GroupActionButton(
modifier: Modifier = Modifier,

View File

@@ -226,11 +226,13 @@ fun GroupSetupScreen(
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val imeBottomDp = with(density) { imeBottomPx.toDp() }
val keyboardOrEmojiHeight =
if (coordinator.isEmojiBoxVisible) coordinator.emojiHeight else imeBottomDp
val fabBottomPadding =
if (keyboardOrEmojiHeight > 0.dp) {
keyboardOrEmojiHeight + 14.dp
if (coordinator.isEmojiBoxVisible) {
// 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 {
18.dp
}

View File

@@ -1433,7 +1433,6 @@ fun FileAttachment(
messageStatus: MessageStatus = MessageStatus.READ
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var downloadProgress by remember { mutableStateOf(0f) }
@@ -1465,7 +1464,30 @@ fun FileAttachment(
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
// 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
val managerState by com.rosetta.messenger.network.FileDownloadManager
.progressOf(attachment.id)
.collectAsState(initial = null)
// Синхронизируем локальный UI с глобальным менеджером
LaunchedEffect(managerState) {
val state = managerState ?: return@LaunchedEffect
downloadProgress = state.progress
downloadStatus = when (state.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
}
}
LaunchedEffect(attachment.id) {
// Если менеджер уже качает этот файл — подхватим состояние оттуда
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
downloadStatus = DownloadStatus.DOWNLOADING
return@LaunchedEffect
}
downloadStatus = if (isDownloadTag(preview)) {
// Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED
@@ -1507,76 +1529,20 @@ fun FileAttachment(
}
}
// 📥 Запуск скачивания через глобальный FileDownloadManager
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
// Streaming: скачиваем во temp file, не в память
val success =
if (isGroupStoredKey(chachaKey)) {
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
false
} else {
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
val bytes = decrypted?.let { decodeBase64Payload(it) }
if (bytes != null) {
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
true
} else {
false
}
}
} else {
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
}
downloadProgress = 0.95f
if (success) {
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
} else {
downloadStatus = DownloadStatus.ERROR
}
} catch (e: Exception) {
e.printStackTrace()
downloadStatus = DownloadStatus.ERROR
} catch (_: OutOfMemoryError) {
System.gc()
downloadStatus = DownloadStatus.ERROR
}
}
downloadStatus = DownloadStatus.DOWNLOADING
downloadProgress = 0f
com.rosetta.messenger.network.FileDownloadManager.download(
context = context,
attachmentId = attachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile
)
}
}
@@ -1623,7 +1589,9 @@ fun FileAttachment(
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Determinate progress like Telegram
CircularProgressIndicator(
progress = downloadProgress.coerceIn(0f, 1f),
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
@@ -1693,10 +1661,14 @@ fun FileAttachment(
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
AnimatedDotsText(
baseText = "Downloading",
color = statusColor,
fontSize = 12.sp
// Telegram-style: "1.2 MB / 5.4 MB"
// CDN download maps to progress 0..0.8
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
}
DownloadStatus.DECRYPTING -> {

View File

@@ -592,11 +592,19 @@ fun MessageBubble(
.IMAGE
}
val isStandaloneGroupInvite =
message.attachments.isEmpty() &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.text.isNotBlank() &&
isGroupInviteCode(message.text)
// Для сообщений только с фото - минимальный padding и тонкий border
// Для фото + caption - padding только внизу для текста
val bubblePadding =
when {
isSafeSystemMessage -> PaddingValues(0.dp)
isStandaloneGroupInvite -> PaddingValues(0.dp)
hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -676,6 +684,8 @@ fun MessageBubble(
val bubbleWidthModifier =
if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width(
photoWidth
@@ -703,46 +713,52 @@ fun MessageBubble(
onClick = onClick,
onLongClick = onLongClick
)
.clip(bubbleShape)
.then(
if (hasOnlyMedia) {
Modifier.border(
width = bubbleBorderWidth,
color =
if (message.isOutgoing
) {
Color.White
.copy(
alpha =
0.15f
)
} else {
if (isDarkTheme
)
Color.White
.copy(
alpha =
0.1f
)
else
Color.Black
.copy(
alpha =
0.08f
)
},
shape = bubbleShape
)
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
if (isStandaloneGroupInvite) {
Modifier
} else {
Modifier.background(bubbleColor)
Modifier.clip(bubbleShape)
.then(
if (hasOnlyMedia) {
Modifier.border(
width = bubbleBorderWidth,
color =
if (message.isOutgoing
) {
Color.White
.copy(
alpha =
0.15f
)
} else {
if (isDarkTheme
)
Color.White
.copy(
alpha =
0.1f
)
else
Color.Black
.copy(
alpha =
0.08f
)
},
shape = bubbleShape
)
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
} else {
Modifier.background(bubbleColor)
}
)
.padding(bubblePadding)
}
)
.padding(bubblePadding)
) {
if (isSafeSystemMessage) {
SafeSystemMessageCard(
@@ -1045,35 +1061,12 @@ fun MessageBubble(
accountPublicKey = currentUserPublicKey,
accountPrivateKey = privateKey,
actionsEnabled = !isSelectionMode,
timestamp = message.timestamp,
messageStatus = displayStatus,
onRetry = onRetry,
onDelete = onDelete,
onOpenGroup = onGroupInviteOpen
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = displayStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = message.isOutgoing,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
} else {
// Telegram-style: текст + время с автоматическим
// переносом
@@ -1275,6 +1268,10 @@ private fun GroupInviteInlineCard(
accountPublicKey: String,
accountPrivateKey: String,
actionsEnabled: Boolean,
timestamp: Date,
messageStatus: MessageStatus,
onRetry: () -> Unit,
onDelete: () -> Unit,
onOpenGroup: (SearchUser) -> Unit
) {
val context = LocalContext.current
@@ -1352,19 +1349,19 @@ private fun GroupInviteInlineCard(
val cardBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.16f)
PrimaryBlue
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.06f)
Color(0xFF222326)
} else {
Color.Black.copy(alpha = 0.03f)
Color(0xFFF5F7FA)
}
val cardBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.22f)
Color.White.copy(alpha = 0.24f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.12f)
Color.White.copy(alpha = 0.1f)
} else {
Color.Black.copy(alpha = 0.08f)
Color.Black.copy(alpha = 0.07f)
}
val titleColor =
if (isOutgoing) Color.White
@@ -1374,6 +1371,12 @@ private fun GroupInviteInlineCard(
if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFA9AFBA)
else Color(0xFF70757F)
val timeColor =
if (isOutgoing) Color.White.copy(alpha = 0.74f)
else if (isDarkTheme) Color(0xFF8E8E93)
else Color(0xFF666666)
val statusColor = if (isOutgoing) Color.White else timeColor
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val accentColor =
when (status) {
@@ -1458,91 +1461,121 @@ private fun GroupInviteInlineCard(
Surface(
modifier = Modifier.fillMaxWidth(),
color = cardBackground,
shape = RoundedCornerShape(12.dp),
shape = RoundedCornerShape(14.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)
) {
Box(
modifier =
Modifier.size(34.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
contentAlignment = Alignment.Center
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
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(
Box(
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)
Modifier.size(34.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
Icon(
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.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) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.8.dp,
color = accentColor
)
} else {
Icon(
imageVector = actionIcon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(12.dp)
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (actionLoading || statusLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.8.dp,
color = accentColor
)
} else {
Icon(
imageVector = actionIcon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = actionLabel,
color = accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.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 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) {
val event =
awaitPointerEvent(
val pass =
if (startedSwipe)
PointerEventPass.Initial
)
else PointerEventPass.Main
val event = awaitPointerEvent(pass)
val change =
event.changes.firstOrNull {
it.id == down.id
@@ -289,6 +292,9 @@ fun SwipeBackContainer(
totalDragY += dragDelta.y
if (!passedSlop) {
// Child (e.g. LazyRow) already consumed — let it handle
if (change.isConsumed) break
val totalDistance =
kotlin.math.sqrt(
totalDragX *

View File

@@ -5,11 +5,14 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -18,7 +21,6 @@ import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,7 +39,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -64,8 +68,10 @@ private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
fun ThemeScreen(
isDarkTheme: Boolean,
currentThemeMode: String,
currentWallpaperId: String,
onBack: () -> Unit,
onThemeModeChange: (String) -> Unit
onThemeModeChange: (String) -> Unit,
onWallpaperChange: (String) -> Unit
) {
val view = LocalView.current
if (!view.isInEditMode) {
@@ -78,7 +84,6 @@ fun ThemeScreen(
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val scope = rememberCoroutineScope()
@@ -95,11 +100,16 @@ fun ThemeScreen(
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
var wallpaperId by remember { mutableStateOf(currentWallpaperId) }
LaunchedEffect(currentThemeMode) {
themeMode = currentThemeMode
}
LaunchedEffect(currentWallpaperId) {
wallpaperId = currentWallpaperId
}
fun resolveThemeIsDark(mode: String): Boolean =
when (mode) {
"dark" -> true
@@ -214,7 +224,7 @@ fun ThemeScreen(
// ═══════════════════════════════════════════════════════
// CHAT PREVIEW - Message bubbles like in real chat
// ═══════════════════════════════════════════════════════
ChatPreview(isDarkTheme = isDarkTheme)
ChatPreview(isDarkTheme = isDarkTheme, wallpaperId = wallpaperId)
Spacer(modifier = Modifier.height(24.dp))
@@ -266,7 +276,27 @@ fun ThemeScreen(
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(32.dp))
TelegramSectionHeader("Chat Wallpaper", secondaryTextColor)
WallpaperSelectorRow(
isDarkTheme = isDarkTheme,
selectedWallpaperId = wallpaperId,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onWallpaperSelected = { selectedId ->
if (selectedId != wallpaperId) {
wallpaperId = selectedId
onWallpaperChange(selectedId)
}
}
)
TelegramInfoText(
text = "Selected wallpaper is used for chat backgrounds.",
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(24.dp))
}
}
@@ -420,16 +450,111 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
)
}
@Composable
private fun WallpaperSelectorRow(
isDarkTheme: Boolean,
selectedWallpaperId: String,
textColor: Color,
secondaryTextColor: Color,
onWallpaperSelected: (String) -> Unit
) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "none") {
WallpaperSelectorItem(
title = "No wallpaper",
wallpaperResId = null,
isSelected = selectedWallpaperId.isBlank(),
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onClick = { onWallpaperSelected("") }
)
}
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
WallpaperSelectorItem(
title = wallpaper.name,
wallpaperResId = wallpaper.drawableRes,
isSelected = wallpaper.id == selectedWallpaperId,
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onClick = { onWallpaperSelected(wallpaper.id) }
)
}
}
}
@Composable
private fun WallpaperSelectorItem(
title: String,
wallpaperResId: Int?,
isSelected: Boolean,
isDarkTheme: Boolean,
textColor: Color,
secondaryTextColor: Color,
onClick: () -> Unit
) {
val borderColor = if (isSelected) Color(0xFF007AFF) else secondaryTextColor.copy(alpha = 0.35f)
Column(
modifier =
Modifier.width(118.dp).clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth().height(76.dp),
shape = RoundedCornerShape(10.dp),
color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor)
) {
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7))
)
}
}
Text(
text = title,
fontSize = 12.sp,
color = if (isSelected) textColor else secondaryTextColor,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1
)
}
}
// ═══════════════════════════════════════════════════════════════════
// 💬 CHAT PREVIEW - Real message bubbles preview
// ═══════════════════════════════════════════════════════════════════
@Composable
private fun ChatPreview(isDarkTheme: Boolean) {
private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
val wallpaperResId = remember(wallpaperId) { ThemeWallpapers.drawableResOrNull(wallpaperId) }
val hasWallpaper = wallpaperResId != null
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
// Message colors matching real ChatDetailScreen
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val otherBubbleColor =
if (hasWallpaper) {
if (isDarkTheme) Color(0xFF2C2E33) else Color.White
} else {
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
val myTextColor = Color.White // White text on blue bubble
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
@@ -444,56 +569,65 @@ private fun ChatPreview(isDarkTheme: Boolean) {
color = chatBgColor,
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Hey! How's it going? 👋",
time = "10:42",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
Box(modifier = Modifier.fillMaxSize()) {
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = "Chat wallpaper preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// Outgoing message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
Column(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MessageBubble(
text = "Hey! All good, just checking out this new theme 😊",
time = "10:43",
isMe = true,
bubbleColor = myBubbleColor,
textColor = myTextColor,
timeColor = myTimeColor,
checkmarkColor = myCheckColor
)
}
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Nice! Looks great! 🔥",
time = "10:44",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
)
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Hey! How's it going? 👋",
time = "10:42",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
)
}
// Outgoing message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
MessageBubble(
text = "Hey! All good, just checking out this new theme 😊",
time = "10:43",
isMe = true,
bubbleColor = myBubbleColor,
textColor = myTextColor,
timeColor = myTimeColor,
checkmarkColor = myCheckColor
)
}
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Nice! Looks great! 🔥",
time = "10:44",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
)
}
}
}
}

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