diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 7266ef2..8fba527 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 } ) diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt index b5c41dd..ca0c418 100644 --- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt @@ -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 = + context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" } + + suspend fun setChatWallpaperId(value: String) { + context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value } + } + // ═════════════════════════════════════════════════════════════ // 🔔 NOTIFICATIONS // ═════════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index a9dcac0..94e405d 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -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 + + /** + * Получить сообщения с 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 } /** DAO для работы с диалогами */ diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt new file mode 100644 index 0000000..13bb6f7 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -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>(emptyMap()) + val downloads: StateFlow> = _downloads.asStateFlow() + + /** Текущие Job'ы — чтобы не запускать повторно */ + private val jobs = mutableMapOf() + + // ─── 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 для конкретного attachment + */ + fun progressOf(attachmentId: String): Flow = + _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)) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 81b9f00..a01d700 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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 + + // � 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 + ) + } + + // �📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda) if (showInAppCamera) { InAppCameraScreen( onDismiss = { showInAppCamera = false }, @@ -2835,5 +2857,5 @@ fun ChatDetailScreen( ) } -} +} // Закрытие outer Box } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index d5b783a..47abeec 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -442,7 +442,11 @@ fun ChatsListScreen( val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() - // 🔥 Пользователи, которые сейчас печатают + // � Active downloads tracking (for header indicator) + val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState() + val hasActiveDownloads = activeDownloads.isNotEmpty() + + // �🔥 Пользователи, которые сейчас печатают 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 == diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt index dbeed8f..4c93f32 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt @@ -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(), diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 3eaad20..8438507 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -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(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, + 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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 1e6f674..e26b8f1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 8dba100..ddde2a8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -7,15 +7,22 @@ import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import com.rosetta.messenger.ui.icons.TelegramIcons import compose.icons.TablerIcons import compose.icons.tablericons.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -25,13 +32,19 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.database.MessageEntity +import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.MessageAttachment +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -44,10 +57,26 @@ import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.SearchUser +import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale // Primary Blue color private val PrimaryBlue = Color(0xFF54A9EB) +/** Вкладки поиска как в Telegram */ +private enum class SearchTab(val title: String) { + CHATS("Chats"), + MEDIA("Media"), + DOWNLOADS("Downloads"), + FILES("Files") +} + /** Отдельная страница поиска пользователей Хедер на всю ширину с полем ввода */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -184,17 +213,20 @@ fun SearchScreen( Scaffold( topBar = { - // Кастомный header с полем ввода на всю ширину - Surface(modifier = Modifier.fillMaxWidth(), color = backgroundColor) { + // Хедер как в Telegram: стрелка назад + поле ввода + Surface( + modifier = Modifier.fillMaxWidth(), + color = backgroundColor + ) { Row( modifier = Modifier.fillMaxWidth() .statusBarsPadding() - .height(64.dp) - .padding(horizontal = 4.dp), + .height(56.dp) + .padding(start = 4.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - // Кнопка назад - с мгновенным закрытием клавиатуры + // Кнопка назад IconButton( onClick = { hideKeyboardInstantly() @@ -203,57 +235,44 @@ fun SearchScreen( } ) { Icon( - imageVector = TablerIcons.ChevronLeft, + painter = TelegramIcons.ArrowBack, contentDescription = "Back", - tint = textColor.copy(alpha = 0.6f) + tint = textColor.copy(alpha = 0.7f) ) } - // Поле ввода на всю оставшуюся ширину - Box(modifier = Modifier.weight(1f).padding(end = 8.dp)) { - TextField( - value = searchQuery, - onValueChange = { searchViewModel.onSearchQueryChange(it) }, - placeholder = { - Text( - "Search users...", - color = secondaryTextColor.copy(alpha = 0.7f), - fontSize = 16.sp - ) - }, - colors = - TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedTextColor = textColor, - unfocusedTextColor = textColor, - cursorColor = PrimaryBlue, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - textStyle = - androidx.compose.ui.text.TextStyle( - fontSize = 16.sp, + // Чистое поле ввода без подчёркивания + BasicTextField( + value = searchQuery, + onValueChange = { searchViewModel.onSearchQueryChange(it) }, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + color = textColor + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(PrimaryBlue), + singleLine = true, + enabled = protocolState == ProtocolState.AUTHENTICATED, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.CenterStart + ) { + if (searchQuery.isEmpty()) { + Text( + "Search", + color = secondaryTextColor.copy(alpha = 0.5f), + fontSize = 18.sp, fontWeight = FontWeight.Normal - ), - singleLine = true, - enabled = protocolState == ProtocolState.AUTHENTICATED, - modifier = - Modifier.fillMaxWidth().focusRequester(focusRequester) - ) - - // Подчеркивание - Box( - modifier = - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .height(2.dp) - .background( - PrimaryBlue.copy(alpha = 0.8f), - RoundedCornerShape(1.dp) - ) - ) - } + ) + } + innerTextField() + } + } + ) // Кнопка очистки AnimatedVisibility( @@ -265,7 +284,8 @@ fun SearchScreen( Icon( painter = TelegramIcons.Close, contentDescription = "Clear", - tint = secondaryTextColor.copy(alpha = 0.6f) + tint = secondaryTextColor.copy(alpha = 0.5f), + modifier = Modifier.size(20.dp) ) } } @@ -274,45 +294,227 @@ fun SearchScreen( }, containerColor = backgroundColor ) { paddingValues -> - // Контент - показываем recent users если поле пустое, иначе результаты - Box( + // ─── Состояние вкладок ─── + var selectedTab by remember { mutableStateOf(SearchTab.CHATS) } + + Column( modifier = Modifier.fillMaxSize() .padding(paddingValues) - // 🔥 ОПТИМИЗАЦИЯ: Скрываем контент до готовности без блокировки - // рендера .drawWithContent { if (isContentReady) { drawContent() } } ) { - if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) { - // Recent Users с аватарками - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) - ) { - item { - Row( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Recent", - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = secondaryTextColor - ) - TextButton(onClick = { RecentSearchesManager.clearAll() }) { - Text("Clear All", fontSize = 13.sp, color = PrimaryBlue) - } + // ═══════ Tab Row как в Telegram ═══════ + ScrollableTabRow( + selectedTabIndex = SearchTab.entries.indexOf(selectedTab), + containerColor = backgroundColor, + contentColor = PrimaryBlue, + edgePadding = 12.dp, + divider = { + Divider( + color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0), + thickness = 0.5.dp + ) + } + ) { + SearchTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { + Text( + text = tab.title, + fontSize = 14.sp, + fontWeight = + if (selectedTab == tab) FontWeight.SemiBold + else FontWeight.Normal, + maxLines = 1 + ) + }, + selectedContentColor = PrimaryBlue, + unselectedContentColor = secondaryTextColor + ) + } + } + + // ═══════ Tab Content ═══════ + when (selectedTab) { + SearchTab.CHATS -> { + ChatsTabContent( + searchQuery = searchQuery, + searchResults = searchResults, + isSearching = isSearching, + recentUsers = recentUsers, + currentUserPublicKey = currentUserPublicKey, + ownAccountName = ownAccountName, + ownAccountUsername = ownAccountUsername, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + avatarRepository = avatarRepository, + searchLottieComposition = searchLottieComposition, + hideKeyboardInstantly = hideKeyboardInstantly, + onUserSelect = onUserSelect + ) + } + SearchTab.MEDIA -> { + MediaTabContent( + currentUserPublicKey = currentUserPublicKey, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } + SearchTab.DOWNLOADS -> { + DownloadsTabContent( + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } + SearchTab.FILES -> { + FilesTabContent( + currentUserPublicKey = currentUserPublicKey, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 💬 CHATS TAB — existing search logic +// ═══════════════════════════════════════════════════════════ + +@Composable +private fun ChatsTabContent( + searchQuery: String, + searchResults: List, + isSearching: Boolean, + recentUsers: List, + currentUserPublicKey: String, + ownAccountName: String, + ownAccountUsername: String, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + avatarRepository: AvatarRepository?, + searchLottieComposition: com.airbnb.lottie.LottieComposition?, + hideKeyboardInstantly: () -> Unit, + onUserSelect: (SearchUser) -> Unit +) { + val context = LocalContext.current + val dividerColor = remember(isDarkTheme) { + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + } + + // ─── Загрузка частых контактов из БД (top dialogs) ─── + var frequentContacts by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(currentUserPublicKey) { + if (currentUserPublicKey.isBlank()) return@LaunchedEffect + withContext(Dispatchers.IO) { + try { + val db = RosettaDatabase.getDatabase(context) + val dialogs = db.dialogDao().getDialogsPaged(currentUserPublicKey, 15, 0) + frequentContacts = dialogs + .filter { + it.opponentKey != currentUserPublicKey && + !it.opponentKey.startsWith("#group:") && + !it.opponentKey.startsWith("group:") && + it.opponentKey != "0x000000000000000000000000000000000000000001" && + it.opponentKey != "0x000000000000000000000000000000000000000002" } + .map { dialog -> + FrequentContact( + publicKey = dialog.opponentKey, + name = dialog.opponentTitle.ifBlank { + dialog.opponentKey.take(6) + "…" + }, + username = dialog.opponentUsername, + verified = dialog.verified, + isOnline = dialog.isOnline == 1 + ) + } + } catch (_: Exception) { } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (searchQuery.isEmpty()) { + // ═══ Idle state: frequent contacts + recent searches ═══ + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + // ─── Горизонтальный ряд частых контактов (как в Telegram) ─── + if (frequentContacts.isNotEmpty()) { + item { + FrequentContactsRow( + contacts = frequentContacts, + avatarRepository = avatarRepository, + isDarkTheme = isDarkTheme, + textColor = textColor, + onClick = { contact -> + hideKeyboardInstantly() + val user = SearchUser( + title = contact.name, + username = contact.username, + publicKey = contact.publicKey, + verified = 0, + online = 0 + ) + RecentSearchesManager.addUser(user) + onUserSelect(user) + } + ) } + // Сепаратор после частых контактов + item { + Divider( + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + + // ─── Recent header (always show with Clear All) ─── + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 14.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Recent", + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + color = PrimaryBlue + ) + if (recentUsers.isNotEmpty()) { + Text( + "Clear All", + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + color = PrimaryBlue, + modifier = Modifier.clickable { RecentSearchesManager.clearAll() } + ) + } + } + } + + // ─── Recent users list (only actually searched users) ─── + if (recentUsers.isNotEmpty()) { items(recentUsers, key = { it.publicKey }) { user -> RecentUserItem( user = user, @@ -327,60 +529,452 @@ fun SearchScreen( }, onRemove = { RecentSearchesManager.removeUser(user.publicKey) } ) + // Сепаратор между элементами + Divider( + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 76.dp) + ) + } + } else { + // Empty state when no recent searches — Lottie search animation + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (searchLottieComposition != null) { + val lottieProgress by animateLottieCompositionAsState( + composition = searchLottieComposition, + iterations = LottieConstants.IterateForever + ) + LottieAnimation( + composition = searchLottieComposition, + progress = { lottieProgress }, + modifier = Modifier.size(100.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Search for chats and users", + fontSize = 14.sp, + color = secondaryTextColor + ) + } } } - } else { - // Search Results - // Проверяем, не ищет ли пользователь сам себя (Saved Messages) - val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase() - val normalizedPublicKey = currentUserPublicKey.lowercase() - val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase() - val normalizedName = ownAccountName.trim().lowercase() - val hasValidOwnName = - ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName) - val isSavedMessagesSearch = - normalizedQuery.isNotEmpty() && - (normalizedPublicKey == normalizedQuery || - normalizedPublicKey.startsWith(normalizedQuery) || - normalizedPublicKey.take(8) == normalizedQuery || - normalizedPublicKey.takeLast(8) == normalizedQuery || - (normalizedUsername.isNotEmpty() && - normalizedUsername.startsWith(normalizedQuery)) || - (hasValidOwnName && - normalizedName.startsWith(normalizedQuery))) + } + } else if (isSearching) { + // ═══ Skeleton loading while searching ═══ + SearchSkeleton(isDarkTheme = isDarkTheme) + } else { + // ═══ Search results ═══ + val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase() + val normalizedPublicKey = currentUserPublicKey.lowercase() + val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase() + val normalizedName = ownAccountName.trim().lowercase() + val hasValidOwnName = + ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName) + val isSavedMessagesSearch = + normalizedQuery.isNotEmpty() && + (normalizedPublicKey == normalizedQuery || + normalizedPublicKey.startsWith(normalizedQuery) || + normalizedPublicKey.take(8) == normalizedQuery || + normalizedPublicKey.takeLast(8) == normalizedQuery || + (normalizedUsername.isNotEmpty() && + normalizedUsername.startsWith(normalizedQuery)) || + (hasValidOwnName && + normalizedName.startsWith(normalizedQuery))) - // Если ищем себя - показываем Saved Messages как первый результат - val resultsWithSavedMessages = - if (isSavedMessagesSearch && - searchResults.none { it.publicKey == currentUserPublicKey } - ) { - listOf( - SearchUser( - title = "Saved Messages", - username = "", - publicKey = currentUserPublicKey, - verified = 0, - online = 1 - ) - ) + searchResults.filter { it.publicKey != currentUserPublicKey } - } else { - searchResults + val resultsWithSavedMessages = + if (isSavedMessagesSearch && + searchResults.none { it.publicKey == currentUserPublicKey } + ) { + listOf( + SearchUser( + title = "Saved Messages", + username = "", + publicKey = currentUserPublicKey, + verified = 0, + online = 1 + ) + ) + searchResults.filter { it.publicKey != currentUserPublicKey } + } else { + searchResults + } + + SearchResultsList( + searchResults = resultsWithSavedMessages, + isSearching = false, + currentUserPublicKey = currentUserPublicKey, + isDarkTheme = isDarkTheme, + preloadedComposition = searchLottieComposition, + onUserClick = { user -> + hideKeyboardInstantly() + if (user.publicKey != currentUserPublicKey) { + RecentSearchesManager.addUser(user) } + onUserSelect(user) + } + ) + } + } +} - SearchResultsList( - searchResults = resultsWithSavedMessages, - isSearching = isSearching, - currentUserPublicKey = currentUserPublicKey, - isDarkTheme = isDarkTheme, - preloadedComposition = searchLottieComposition, - onUserClick = { user -> - // Мгновенно закрываем клавиатуру - hideKeyboardInstantly() - // Сохраняем пользователя в историю (кроме Saved Messages) - if (user.publicKey != currentUserPublicKey) { - RecentSearchesManager.addUser(user) +// ═══════════════════════════════════════════════════════════ +// 👥 FREQUENT CONTACTS ROW (horizontal, like Telegram) +// ═══════════════════════════════════════════════════════════ + +private data class FrequentContact( + val publicKey: String, + val name: String, + val username: String, + val verified: Int = 0, + val isOnline: Boolean = false +) + +@Composable +private fun FrequentContactsRow( + contacts: List, + avatarRepository: AvatarRepository?, + isDarkTheme: Boolean, + textColor: Color, + onClick: (FrequentContact) -> Unit +) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentPadding = PaddingValues(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(contacts, key = { it.publicKey }) { contact -> + Column( + modifier = Modifier + .width(72.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onClick(contact) } + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(contentAlignment = Alignment.BottomEnd) { + AvatarImage( + publicKey = contact.publicKey, + avatarRepository = avatarRepository, + size = 54.dp, + isDarkTheme = isDarkTheme, + showOnlineIndicator = false, + isOnline = false, + displayName = contact.name + ) + if (contact.isOnline) { + Box( + modifier = Modifier + .size(16.dp) + .offset(x = 1.dp, y = 1.dp) + .background( + if (isDarkTheme) Color(0xFF1A1A1A) else Color.White, + CircleShape + ) + .padding(2.5.dp) + .background(Color(0xFF4CAF50), CircleShape) + ) + } + } + Spacer(modifier = Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = contact.name.split(" ").firstOrNull() ?: contact.name, + fontSize = 12.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Normal, + modifier = if (contact.verified != 0) Modifier.widthIn(max = 52.dp) else Modifier + ) + if (contact.verified != 0) { + Spacer(modifier = Modifier.width(2.dp)) + VerifiedBadge( + verified = contact.verified, + size = 12 + ) + } + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 💀 SEARCH SKELETON (shimmer loading) +// ═══════════════════════════════════════════════════════════ + +@Composable +private fun SearchSkeleton(isDarkTheme: Boolean) { + val shimmerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + val highlightColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF5F5F5) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_translate" + ) + + val shimmerBrush = androidx.compose.ui.graphics.Brush.linearGradient( + colors = listOf(shimmerColor, highlightColor, shimmerColor), + start = androidx.compose.ui.geometry.Offset(translateAnim - 200f, 0f), + end = androidx.compose.ui.geometry.Offset(translateAnim, 0f) + ) + + Column(modifier = Modifier.fillMaxSize()) { + repeat(8) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Skeleton avatar + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + // Skeleton name + Box( + modifier = Modifier + .fillMaxWidth(fraction = if (it % 2 == 0) 0.6f else 0.45f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + Spacer(modifier = Modifier.height(8.dp)) + // Skeleton subtitle + Box( + modifier = Modifier + .fillMaxWidth(fraction = if (it % 2 == 0) 0.35f else 0.5f) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerBrush) + ) + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 🖼️ MEDIA TAB — grid of images from all chats +// ═══════════════════════════════════════════════════════════ + +/** Parsed media item for the grid display */ +private data class MediaItem( + val messageId: String, + val timestamp: Long, + val preview: String, // blurhash or base64 + val width: Int, + val height: Int +) + +@Composable +private fun MediaTabContent( + currentUserPublicKey: String, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color +) { + val context = LocalContext.current + var mediaItems by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(currentUserPublicKey) { + if (currentUserPublicKey.isBlank()) { + isLoading = false + return@LaunchedEffect + } + isLoading = true + withContext(Dispatchers.IO) { + try { + val db = RosettaDatabase.getDatabase(context) + val messages = db.messageDao().getMessagesWithMedia(currentUserPublicKey, 200, 0) + val items = mutableListOf() + for (msg in messages) { + try { + val arr = JSONArray(msg.attachments) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + val type = obj.optInt("type", -1) + if (type == AttachmentType.IMAGE.value) { + items.add( + MediaItem( + messageId = msg.messageId, + timestamp = msg.timestamp, + preview = obj.optString("preview", ""), + width = obj.optInt("width", 100), + height = obj.optInt("height", 100) + ) + ) } - onUserSelect(user) + } + } catch (_: Exception) { } + } + mediaItems = items + } catch (_: Exception) { } + } + isLoading = false + } + + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp)) + } + } else if (mediaItems.isEmpty()) { + EmptyTabPlaceholder( + iconRes = R.drawable.search_media_filled, + title = "No media yet", + subtitle = "Photos and videos will appear here", + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(mediaItems, key = { "${it.messageId}_${it.preview.hashCode()}" }) { item -> + MediaGridItem(item = item, isDarkTheme = isDarkTheme) + } + } + } +} + +@Composable +private fun MediaGridItem(item: MediaItem, isDarkTheme: Boolean) { + val bitmap = remember(item.preview) { + if (item.preview.isNotBlank() && !item.preview.contains("::")) { + try { + // Try blurhash decode + com.vanniktech.blurhash.BlurHash.decode( + item.preview, 32, 32 + ) + } catch (_: Exception) { + null + } + } else null + } + + Box( + modifier = Modifier + .aspectRatio(1f) + .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)) + .clip(RoundedCornerShape(2.dp)), + contentAlignment = Alignment.Center + ) { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Icon( + painter = painterResource(R.drawable.search_media_filled), + contentDescription = null, + tint = if (isDarkTheme) Color(0xFF555555) else Color(0xFFCCCCCC), + modifier = Modifier.size(28.dp) + ) + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 📥 DOWNLOADS TAB — files in rosetta_downloads dir +// ═══════════════════════════════════════════════════════════ + +private data class DownloadedFile( + val file: File, + val name: String, + val size: Long, + val lastModified: Long +) + +@Composable +private fun DownloadsTabContent( + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color +) { + val context = LocalContext.current + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + isLoading = true + withContext(Dispatchers.IO) { + try { + val downloadsDir = File(context.filesDir, "rosetta_downloads") + if (downloadsDir.exists()) { + files = downloadsDir.listFiles() + ?.filter { it.isFile } + ?.sortedByDescending { it.lastModified() } + ?.map { DownloadedFile( + file = it, + name = it.name, + size = it.length(), + lastModified = it.lastModified() + ) } ?: emptyList() + } + } catch (_: Exception) { } + } + isLoading = false + } + + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp)) + } + } else if (files.isEmpty()) { + EmptyTabPlaceholder( + iconRes = R.drawable.msg_download, + title = "No downloads yet", + subtitle = "Downloaded files will appear here", + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(files, key = { it.name }) { downloadedFile -> + DownloadFileItem( + downloadedFile = downloadedFile, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + onClick = { + openDownloadedFile(context, downloadedFile.file) } ) } @@ -388,6 +982,313 @@ fun SearchScreen( } } +@Composable +private fun DownloadFileItem( + downloadedFile: DownloadedFile, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + onClick: () -> Unit +) { + val ext = downloadedFile.name.substringAfterLast('.', "").uppercase() + val iconColor = getFileIconColor(ext) + val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // File icon circle + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(iconColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.InsertDriveFile, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = downloadedFile.name, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${formatFileSizeSearch(downloadedFile.size)} · ${dateFormat.format(Date(downloadedFile.lastModified))}", + fontSize = 13.sp, + color = secondaryTextColor + ) + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 📁 FILES TAB — file attachments from DB +// ═══════════════════════════════════════════════════════════ + +private data class FileItem( + val messageId: String, + val timestamp: Long, + val fileName: String, + val fileSize: Long, + val attachmentId: String +) + +@Composable +private fun FilesTabContent( + currentUserPublicKey: String, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color +) { + val context = LocalContext.current + var fileItems by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(currentUserPublicKey) { + if (currentUserPublicKey.isBlank()) { + isLoading = false + return@LaunchedEffect + } + isLoading = true + withContext(Dispatchers.IO) { + try { + val db = RosettaDatabase.getDatabase(context) + val messages = db.messageDao().getMessagesWithFiles(currentUserPublicKey, 200, 0) + val items = mutableListOf() + for (msg in messages) { + try { + val arr = JSONArray(msg.attachments) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + val type = obj.optInt("type", -1) + if (type == AttachmentType.FILE.value) { + val preview = obj.optString("preview", "") + val parsed = parseFilePreviewSearch(preview) + items.add( + FileItem( + messageId = msg.messageId, + timestamp = msg.timestamp, + fileName = parsed.second, + fileSize = parsed.first, + attachmentId = obj.optString("id", "") + ) + ) + } + } + } catch (_: Exception) { } + } + fileItems = items + } catch (_: Exception) { } + } + isLoading = false + } + + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp)) + } + } else if (fileItems.isEmpty()) { + EmptyTabPlaceholder( + iconRes = R.drawable.search_files_filled, + title = "No files yet", + subtitle = "Shared files will appear here", + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } else { + val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item -> + FileListItem( + item = item, + dateFormat = dateFormat, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor + ) + } + } + } +} + +@Composable +private fun FileListItem( + item: FileItem, + dateFormat: SimpleDateFormat, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color +) { + val ext = item.fileName.substringAfterLast('.', "").uppercase() + val iconColor = getFileIconColor(ext) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // File icon circle + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(iconColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.InsertDriveFile, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.fileName, + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${formatFileSizeSearch(item.fileSize)} · ${dateFormat.format(Date(item.timestamp))}", + fontSize = 13.sp, + color = secondaryTextColor + ) + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 🎨 Helpers +// ═══════════════════════════════════════════════════════════ + +@Composable +private fun EmptyTabPlaceholder( + iconRes: Int, + title: String, + subtitle: String, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color +) { + Column( + modifier = Modifier.fillMaxSize().padding(top = 80.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = secondaryTextColor.copy(alpha = 0.4f), + modifier = Modifier.size(56.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = title, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = textColor.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + fontSize = 14.sp, + color = secondaryTextColor + ) + } +} + +private fun getFileIconColor(ext: String): Color { + return when (ext.uppercase()) { + "PDF" -> Color(0xFFE53935) + "DOC", "DOCX" -> Color(0xFF1565C0) + "XLS", "XLSX" -> Color(0xFF2E7D32) + "PPT", "PPTX" -> Color(0xFFE65100) + "ZIP", "RAR", "7Z", "TAR", "GZ" -> Color(0xFF6A1B9A) + "MP3", "WAV", "OGG", "FLAC", "AAC" -> Color(0xFFEF6C00) + "MP4", "AVI", "MKV", "MOV", "WEBM" -> Color(0xFF0277BD) + "JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG" -> Color(0xFF00838F) + "APK" -> Color(0xFF33691E) + "TXT" -> Color(0xFF455A64) + else -> PrimaryBlue + } +} + +private fun formatFileSizeSearch(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024L * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0)) + else -> String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0)) + } +} + +private val uuidRegexSearch = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + +private fun parseFilePreviewSearch(preview: String): Pair { + val parts = preview.split("::") + return when { + parts.size >= 3 && uuidRegexSearch.matches(parts[0]) -> { + val size = parts[1].toLongOrNull() ?: 0L + val name = parts.drop(2).joinToString("::") + Pair(size, name) + } + parts.size >= 2 && !uuidRegexSearch.matches(parts[0]) -> { + val size = parts[0].toLongOrNull() ?: 0L + Pair(size, parts.drop(1).joinToString("::")) + } + parts.size >= 2 -> { + Pair(0L, parts.drop(1).joinToString("::")) + } + else -> Pair(0L, "File") + } +} + +private fun openDownloadedFile(context: Context, file: File) { + try { + val uri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.provider", file + ) + val ext = file.name.substringAfterLast('.', "").lowercase() + val mimeType = android.webkit.MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(ext) ?: "application/octet-stream" + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) + } catch (e: Exception) { + android.widget.Toast.makeText(context, "Cannot open file", android.widget.Toast.LENGTH_SHORT).show() + } +} + @Composable private fun RecentUserItem( user: SearchUser, @@ -396,7 +1297,7 @@ private fun RecentUserItem( secondaryTextColor: Color, avatarRepository: AvatarRepository?, onClick: () -> Unit, - onRemove: () -> Unit + onRemove: (() -> Unit)? = null ) { val displayName = user.title.ifEmpty { user.username.ifEmpty { user.publicKey.take(8) + "..." } } @@ -456,14 +1357,16 @@ private fun RecentUserItem( } } - // Remove button - IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { - Icon( - painter = TelegramIcons.Close, - contentDescription = "Remove", - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier.size(20.dp) - ) + // Remove button (only when onRemove provided) + if (onRemove != null) { + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { + Icon( + painter = TelegramIcons.Close, + contentDescription = "Remove", + tint = secondaryTextColor.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp) + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 040b747..5cb7337 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 -> { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index e674ffa..06a795c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 + ) + } + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/DownloadIndicator.kt b/app/src/main/java/com/rosetta/messenger/ui/components/DownloadIndicator.kt new file mode 100644 index 0000000..3c5a7c7 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/DownloadIndicator.kt @@ -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 + ) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index 02dfb36..7c2f6ef 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -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 * diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index 2916977..f771de8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -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(null) } var darkOptionCenter by remember { mutableStateOf(null) } var systemOptionCenter by remember { mutableStateOf(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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt new file mode 100644 index 0000000..7aa5668 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt @@ -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 = + 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 +} diff --git a/app/src/main/res/drawable-hdpi/msg_download.png b/app/src/main/res/drawable-hdpi/msg_download.png new file mode 100644 index 0000000..c743fd1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/msg_download.png differ diff --git a/app/src/main/res/drawable-hdpi/search_files_filled.png b/app/src/main/res/drawable-hdpi/search_files_filled.png new file mode 100644 index 0000000..a811a12 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/search_files_filled.png differ diff --git a/app/src/main/res/drawable-hdpi/search_links_filled.png b/app/src/main/res/drawable-hdpi/search_links_filled.png new file mode 100644 index 0000000..2061163 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/search_links_filled.png differ diff --git a/app/src/main/res/drawable-hdpi/search_media_filled.png b/app/src/main/res/drawable-hdpi/search_media_filled.png new file mode 100644 index 0000000..405ef40 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/search_media_filled.png differ diff --git a/app/src/main/res/drawable-hdpi/search_music_filled.png b/app/src/main/res/drawable-hdpi/search_music_filled.png new file mode 100644 index 0000000..0ab462c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/search_music_filled.png differ diff --git a/app/src/main/res/drawable-mdpi/msg_download.png b/app/src/main/res/drawable-mdpi/msg_download.png new file mode 100644 index 0000000..cde93e5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/msg_download.png differ diff --git a/app/src/main/res/drawable-mdpi/search_files_filled.png b/app/src/main/res/drawable-mdpi/search_files_filled.png new file mode 100644 index 0000000..155e8d4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/search_files_filled.png differ diff --git a/app/src/main/res/drawable-mdpi/search_links_filled.png b/app/src/main/res/drawable-mdpi/search_links_filled.png new file mode 100644 index 0000000..4f6b0b6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/search_links_filled.png differ diff --git a/app/src/main/res/drawable-mdpi/search_media_filled.png b/app/src/main/res/drawable-mdpi/search_media_filled.png new file mode 100644 index 0000000..d476a18 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/search_media_filled.png differ diff --git a/app/src/main/res/drawable-mdpi/search_music_filled.png b/app/src/main/res/drawable-mdpi/search_music_filled.png new file mode 100644 index 0000000..84479c8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/search_music_filled.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_1.png b/app/src/main/res/drawable-nodpi/wallpaper_back_1.png new file mode 100644 index 0000000..b490b49 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_1.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_10.png b/app/src/main/res/drawable-nodpi/wallpaper_back_10.png new file mode 100644 index 0000000..482c8b1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_10.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_11.png b/app/src/main/res/drawable-nodpi/wallpaper_back_11.png new file mode 100644 index 0000000..0518932 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_11.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_2.png b/app/src/main/res/drawable-nodpi/wallpaper_back_2.png new file mode 100644 index 0000000..4946de6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_2.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_3.png b/app/src/main/res/drawable-nodpi/wallpaper_back_3.png new file mode 100644 index 0000000..b490b49 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_3.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_4.png b/app/src/main/res/drawable-nodpi/wallpaper_back_4.png new file mode 100644 index 0000000..b8cd264 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_4.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_5.png b/app/src/main/res/drawable-nodpi/wallpaper_back_5.png new file mode 100644 index 0000000..3ec7b78 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_5.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_6.png b/app/src/main/res/drawable-nodpi/wallpaper_back_6.png new file mode 100644 index 0000000..ac9297f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_6.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_7.png b/app/src/main/res/drawable-nodpi/wallpaper_back_7.png new file mode 100644 index 0000000..4df23a3 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_7.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_8.png b/app/src/main/res/drawable-nodpi/wallpaper_back_8.png new file mode 100644 index 0000000..9994940 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_8.png differ diff --git a/app/src/main/res/drawable-nodpi/wallpaper_back_9.png b/app/src/main/res/drawable-nodpi/wallpaper_back_9.png new file mode 100644 index 0000000..1490358 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/wallpaper_back_9.png differ diff --git a/app/src/main/res/drawable-xhdpi/msg_download.png b/app/src/main/res/drawable-xhdpi/msg_download.png new file mode 100644 index 0000000..b92ea21 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/msg_download.png differ diff --git a/app/src/main/res/drawable-xhdpi/search_files_filled.png b/app/src/main/res/drawable-xhdpi/search_files_filled.png new file mode 100644 index 0000000..7550ee3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/search_files_filled.png differ diff --git a/app/src/main/res/drawable-xhdpi/search_links_filled.png b/app/src/main/res/drawable-xhdpi/search_links_filled.png new file mode 100644 index 0000000..bd256d9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/search_links_filled.png differ diff --git a/app/src/main/res/drawable-xhdpi/search_media_filled.png b/app/src/main/res/drawable-xhdpi/search_media_filled.png new file mode 100644 index 0000000..769fe26 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/search_media_filled.png differ diff --git a/app/src/main/res/drawable-xhdpi/search_music_filled.png b/app/src/main/res/drawable-xhdpi/search_music_filled.png new file mode 100644 index 0000000..65dc04f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/search_music_filled.png differ diff --git a/app/src/main/res/drawable-xxhdpi/msg_download.png b/app/src/main/res/drawable-xxhdpi/msg_download.png new file mode 100644 index 0000000..42f74ec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/msg_download.png differ diff --git a/app/src/main/res/drawable-xxhdpi/search_files_filled.png b/app/src/main/res/drawable-xxhdpi/search_files_filled.png new file mode 100644 index 0000000..1c7949d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/search_files_filled.png differ diff --git a/app/src/main/res/drawable-xxhdpi/search_links_filled.png b/app/src/main/res/drawable-xxhdpi/search_links_filled.png new file mode 100644 index 0000000..81e39eb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/search_links_filled.png differ diff --git a/app/src/main/res/drawable-xxhdpi/search_media_filled.png b/app/src/main/res/drawable-xxhdpi/search_media_filled.png new file mode 100644 index 0000000..365912f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/search_media_filled.png differ diff --git a/app/src/main/res/drawable-xxhdpi/search_music_filled.png b/app/src/main/res/drawable-xxhdpi/search_music_filled.png new file mode 100644 index 0000000..cf9c1ec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/search_music_filled.png differ