From 14ab10a1a02e1afd336cd5fc33845fa83c722172 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 22 Feb 2026 15:59:27 +0500 Subject: [PATCH] feat: Implement file selection and gallery-as-file functionality in attachment UI --- app/src/main/AndroidManifest.xml | 1 + .../messenger/ui/chats/ChatDetailScreen.kt | 69 +- .../messenger/ui/chats/ChatViewModel.kt | 16 + .../ui/chats/attach/AttachAlertFileLayout.kt | 842 ++++++++++++++++-- .../ui/chats/attach/ChatAttachAlert.kt | 11 +- .../chats/components/AttachmentComponents.kt | 19 +- app/src/main/res/drawable/files_document.xml | 10 + app/src/main/res/drawable/files_folder.xml | 10 + app/src/main/res/drawable/files_gallery.xml | 10 + app/src/main/res/drawable/files_storage.xml | 10 + 10 files changed, 942 insertions(+), 56 deletions(-) create mode 100644 app/src/main/res/drawable/files_document.xml create mode 100644 app/src/main/res/drawable/files_folder.xml create mode 100644 app/src/main/res/drawable/files_gallery.xml create mode 100644 app/src/main/res/drawable/files_storage.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41a220b..d6699f1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + 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 669f9c7..d114486 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 @@ -326,7 +326,34 @@ fun ChatDetailScreen( } } - // πŸ“„ File picker launcher + // �️ Gallery-as-file launcher (sends images as compressed files, not as photos) + val galleryAsFileLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + scope.launch { + val fileName = MediaUtils.getFileName(context, uri) + val fileSize = MediaUtils.getFileSize(context, uri) + + if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { + android.widget.Toast.makeText( + context, + "File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)", + android.widget.Toast.LENGTH_LONG + ).show() + return@launch + } + + val base64 = MediaUtils.uriToBase64File(context, uri) + if (base64 != null) { + viewModel.sendFileMessage(base64, fileName, fileSize) + } + } + } + } + + // οΏ½πŸ“„ File picker launcher val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() @@ -2334,6 +2361,46 @@ fun ChatDetailScreen( onOpenFilePicker = { filePickerLauncher.launch("*/*") }, + onGalleryFilesSelected = { uris -> + showMediaPicker = false + inputFocusTrigger++ + scope.launch { + for (uri in uris) { + val fileName = MediaUtils.getFileName(context, uri) + val fileSize = MediaUtils.getFileSize(context, uri) + if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { + android.widget.Toast.makeText( + context, + "File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)", + android.widget.Toast.LENGTH_LONG + ).show() + continue + } + val base64 = MediaUtils.uriToBase64File(context, uri) + if (base64 != null) { + viewModel.sendFileMessage(base64, fileName, fileSize) + } + } + } + }, + onFileSelected = { uri, fileName, fileSize -> + showMediaPicker = false + inputFocusTrigger++ + scope.launch { + if (fileSize > MediaUtils.MAX_FILE_SIZE_MB * 1024 * 1024) { + android.widget.Toast.makeText( + context, + "File too large (max ${MediaUtils.MAX_FILE_SIZE_MB} MB)", + android.widget.Toast.LENGTH_LONG + ).show() + return@launch + } + val base64 = MediaUtils.uriToBase64File(context, uri) + if (base64 != null) { + viewModel.sendFileMessage(base64, fileName, fileSize) + } + } + }, onAvatarClick = { viewModel.sendAvatarMessage() }, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index df6870f..c33f3ce 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -3202,6 +3202,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch(Dispatchers.IO) { try { + // πŸ’Ύ БохраняСм Ρ„Π°ΠΉΠ» локально Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚Π΅Π»ΡŒ ΠΌΠΎΠ³ Π΅Π³ΠΎ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ + try { + val app = getApplication() + val downloadsDir = java.io.File(app.filesDir, "rosetta_downloads").apply { mkdirs() } + val localFile = java.io.File(downloadsDir, fileName) + if (!localFile.exists()) { + val base64Data = if (fileBase64.contains(",")) { + fileBase64.substringAfter(",") + } else { + fileBase64 + } + val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT) + localFile.writeBytes(bytes) + } + } catch (_: Exception) {} + val encryptResult = MessageCrypto.encryptForSending(text, recipient) val encryptedContent = encryptResult.ciphertext val encryptedKey = encryptResult.encryptedKey diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt index 5c54e8b..f376948 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertFileLayout.kt @@ -1,77 +1,817 @@ package com.rosetta.messenger.ui.chats.attach +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.text.format.Formatter +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.rosetta.messenger.ui.icons.TelegramIcons +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.rosetta.messenger.R +import com.rosetta.messenger.ui.chats.components.ThumbnailPosition import com.rosetta.messenger.ui.onboarding.PrimaryBlue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +// ═══════════════════════════════════════════════════════════ +// Telegram‑style "Select File" tab for the attach alert. +// Shows: Internal Storage, App folder, Gallery + Recent files. +// Supports directory browsing with back‑stack navigation. +// ═══════════════════════════════════════════════════════════ + +// ── Color palette (matches Telegram attach icon colors) ── + +private val StorageGreen = Color(0xFF4CAF50) +private val FolderBlue = Color(0xFF2196F3) +private val GalleryOrange = Color(0xFFFF9800) +private val FileGray = Color(0xFF9E9E9E) + +// ── Data models ── + +private data class FileEntry( + val file: File, + val name: String, + val sizeFormatted: String, + val dateFormatted: String, + val isDirectory: Boolean, + val extension: String, + val isImage: Boolean +) + +// ── History entry for navigation stack ── + +private data class HistoryEntry( + val dir: File, + val title: String +) /** - * File/Document tab content for the attach alert. - * Phase 1: Styled button that launches the system file picker. + * Telegram‑style file browser tab. + * + * @param onFileSelected called with a [Uri] + file name + file size when user picks a file + * @param onGalleryFilesSelected called with list of media URIs to send as files + * @param onOpenSystemPicker fallback to system file picker + * @param galleryMediaItems pre-loaded media items from the ViewModel (already permission-checked) + * @param isDarkTheme current theme flag */ @Composable internal fun AttachAlertFileLayout( - onOpenFilePicker: () -> Unit, + onFileSelected: (Uri, String, Long) -> Unit, + onGalleryFilesSelected: (List) -> Unit, + onOpenSystemPicker: () -> Unit, + galleryMediaItems: List, isDarkTheme: Boolean, modifier: Modifier = Modifier ) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val secondaryTextColor = Color(0xFF8E8E93) + val context = LocalContext.current + val canBrowseStorage = remember { + android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R || + Environment.isExternalStorageManager() + } + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryText = Color(0xFF8E8E93) + val dividerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA) + val headerColor = PrimaryBlue + val bgColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White + val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center + // ── Navigation state ── + var currentDir by remember { mutableStateOf(null) } + var currentTitle by remember { mutableStateOf("Select File") } + var history by remember { mutableStateOf(listOf()) } + var searchQuery by remember { mutableStateOf("") } + var isSearching by remember { mutableStateOf(false) } + + // ── Gallery mode state ── + var showGallery by remember { mutableStateOf(false) } + var gallerySelectedOrder by remember { mutableStateOf>(emptyList()) } + + // ── Recent files (loaded once) ── + var recentFiles by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(Unit) { + recentFiles = withContext(Dispatchers.IO) { loadRecentFiles(context) } + } + + // ── Directory contents (loaded when currentDir changes) ── + var dirContents by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(currentDir) { + dirContents = if (currentDir != null) { + withContext(Dispatchers.IO) { listDirectoryContents(context, currentDir!!) } + } else emptyList() + } + + // ── Search filter ── + val displayedRecentFiles = if (searchQuery.isBlank()) recentFiles + else recentFiles.filter { it.name.contains(searchQuery, ignoreCase = true) } + val displayedDirContents = if (searchQuery.isBlank()) dirContents + else dirContents.filter { it.name.contains(searchQuery, ignoreCase = true) } + + // ── Back handler ── + val isAtRoot = currentDir == null + + fun navigateBack() { + if (history.isNotEmpty()) { + val prev = history.last() + history = history.dropLast(1) + currentDir = prev.dir + currentTitle = prev.title + } else { + currentDir = null + currentTitle = "Select File" + } + searchQuery = "" + isSearching = false + } + + fun navigateToDir(dir: File) { + // Push current state + history = history + HistoryEntry(currentDir ?: Environment.getExternalStorageDirectory(), currentTitle) + currentDir = dir + currentTitle = dir.name + searchQuery = "" + isSearching = false + } + + if (showGallery) { + BackHandler { + showGallery = false + gallerySelectedOrder = emptyList() + } + } else if (!isAtRoot) { + BackHandler { navigateBack() } + } + + Column( + modifier = modifier + .fillMaxSize() + .background(bgColor) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) - ) { - Icon( - painter = TelegramIcons.File, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(72.dp) - ) - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = "Send a File", - color = textColor, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Browse files on your device and share them in the chat", - color = secondaryTextColor, - fontSize = 14.sp, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(28.dp)) - Button( - onClick = onOpenFilePicker, - colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .padding(horizontal = 24.dp) - ) { - Text( - text = "Choose File", - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) + // ── Top bar ── + val displayTitle = when { + showGallery -> if (gallerySelectedOrder.isNotEmpty()) + "${gallerySelectedOrder.size} selected" else "Gallery" + else -> currentTitle + } + FilePickerTopBar( + title = displayTitle, + showBack = showGallery || !isAtRoot, + isSearching = isSearching, + searchQuery = searchQuery, + onBackClick = { + if (showGallery) { + showGallery = false + gallerySelectedOrder = emptyList() + } else { + navigateBack() + } + }, + onSearchToggle = { isSearching = !isSearching; if (!isSearching) searchQuery = "" }, + onSearchQueryChange = { searchQuery = it }, + textColor = textColor, + bgColor = bgColor + ) + + Divider(thickness = 0.5.dp, color = dividerColor) + + // ── Content ── + if (showGallery) { + // ── Gallery mode: media grid for sending photos as files ── + Box(modifier = Modifier.fillMaxSize()) { + if (galleryMediaItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("No photos or videos", color = secondaryText, fontSize = 15.sp) + } + } else { + val gridState = rememberLazyGridState() + Column(modifier = Modifier.fillMaxSize()) { + MediaGrid( + mediaItems = galleryMediaItems, + selectedItemOrder = gallerySelectedOrder, + showCameraItem = false, + gridState = gridState, + onCameraClick = {}, + onItemClick = { item, _ -> + gallerySelectedOrder = toggleGallerySelection( + gallerySelectedOrder, item.id, 10 + ) + }, + onItemCheckClick = { item -> + gallerySelectedOrder = toggleGallerySelection( + gallerySelectedOrder, item.id, 10 + ) + }, + onItemLongClick = { item -> + gallerySelectedOrder = toggleGallerySelection( + gallerySelectedOrder, item.id, 10 + ) + }, + isDarkTheme = isDarkTheme, + modifier = Modifier.weight(1f) + ) + + // Send button when items selected + if (gallerySelectedOrder.isNotEmpty()) { + val count = gallerySelectedOrder.size + Button( + onClick = { + val uris = gallerySelectedOrder.mapNotNull { id -> + galleryMediaItems.find { it.id == id }?.uri + } + if (uris.isNotEmpty()) { + onGalleryFilesSelected(uris) + } + }, + colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .height(48.dp) + ) { + Text( + text = if (count == 1) "Send 1 file" else "Send $count files", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } else if (isAtRoot) { + // Root screen + LazyColumn(modifier = Modifier.fillMaxSize()) { + // Internal Storage + item(key = "root_storage") { + RootItemRow( + title = "Internal Storage", + subtitle = getStorageInfo(context), + iconRes = R.drawable.files_storage, + iconBg = StorageGreen, + textColor = textColor, + secondaryText = secondaryText, + onClick = { + if (canBrowseStorage) { + navigateToDir(Environment.getExternalStorageDirectory()) + } else { + onOpenSystemPicker() + } + } + ) + } + + // Rosetta folder (if exists) + item(key = "root_rosetta") { + val rosettaDir = File( + Environment.getExternalStorageDirectory(), "Download/Rosetta" + ) + if (rosettaDir.exists() && rosettaDir.isDirectory) { + RootItemRow( + title = "Rosetta", + subtitle = "App folder", + iconRes = R.drawable.files_folder, + iconBg = FolderBlue, + textColor = textColor, + secondaryText = secondaryText, + onClick = { navigateToDir(rosettaDir) } + ) + } + } + + // Gallery + item(key = "root_gallery") { + RootItemRow( + title = "Gallery", + subtitle = "Send images without compression", + iconRes = R.drawable.files_gallery, + iconBg = GalleryOrange, + textColor = textColor, + secondaryText = secondaryText, + onClick = { showGallery = true } + ) + } + + // ── Recent files section ── + if (displayedRecentFiles.isNotEmpty()) { + item(key = "recent_divider") { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(surfaceColor) + ) + } + item(key = "recent_header") { + Text( + text = "Recent files", + color = headerColor, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 8.dp) + ) + } + items(displayedRecentFiles, key = { "recent_${it.file.absolutePath}" }) { entry -> + FileItemRow( + item = entry, + textColor = textColor, + secondaryText = secondaryText, + dividerColor = dividerColor, + isDarkTheme = isDarkTheme, + onClick = { + onFileSelected( + Uri.fromFile(entry.file), + entry.name, + entry.file.length() + ) + } + ) + } + } else if (searchQuery.isBlank()) { + item(key = "recent_empty_divider") { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(surfaceColor) + ) + } + item(key = "recent_empty") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No recent files", + color = secondaryText, + fontSize = 14.sp + ) + } + } + } + } + } else { + // Directory browser + if (displayedDirContents.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = if (searchQuery.isNotBlank()) "No results" else "Empty folder", + color = secondaryText, + fontSize = 15.sp + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(displayedDirContents, key = { it.file.absolutePath }) { entry -> + if (entry.isDirectory) { + FolderItemRow( + item = entry, + textColor = textColor, + secondaryText = secondaryText, + dividerColor = dividerColor, + onClick = { navigateToDir(entry.file) } + ) + } else { + FileItemRow( + item = entry, + textColor = textColor, + secondaryText = secondaryText, + dividerColor = dividerColor, + isDarkTheme = isDarkTheme, + onClick = { + onFileSelected( + Uri.fromFile(entry.file), + entry.name, + entry.file.length() + ) + } + ) + } + } + } } } } } + +// ═══════════════════════════════════════════════════════════ +// Composable building blocks +// ═══════════════════════════════════════════════════════════ + +@Composable +private fun FilePickerTopBar( + title: String, + showBack: Boolean, + isSearching: Boolean, + searchQuery: String, + onBackClick: () -> Unit, + onSearchToggle: () -> Unit, + onSearchQueryChange: (String) -> Unit, + textColor: Color, + bgColor: Color +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(bgColor) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (showBack) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = textColor + ) + } + } else { + Spacer(modifier = Modifier.width(12.dp)) + } + + if (isSearching) { + TextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + placeholder = { + Text("Search", color = Color(0xFF8E8E93), fontSize = 16.sp) + }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = PrimaryBlue, + focusedTextColor = textColor, + unfocusedTextColor = textColor + ), + modifier = Modifier.weight(1f) + ) + } else { + Text( + text = title, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + IconButton(onClick = onSearchToggle) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = textColor + ) + } + } +} + +/** Root‑level special item row (Internal Storage, Rosetta, Gallery) */ +@Composable +private fun RootItemRow( + title: String, + subtitle: String, + iconRes: Int, + iconBg: Color, + textColor: Color, + secondaryText: Color, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(iconBg), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = subtitle, + color = secondaryText, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** Folder row in directory browser */ +@Composable +private fun FolderItemRow( + item: FileEntry, + textColor: Color, + secondaryText: Color, + dividerColor: Color, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(FolderBlue), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.files_folder), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + color = textColor, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "Folder", + color = secondaryText, + fontSize = 13.sp + ) + } + } + Divider( + thickness = 0.5.dp, + color = dividerColor, + modifier = Modifier.padding(start = 74.dp) + ) + } +} + +/** File row with thumbnail/icon, name, size and date */ +@Composable +private fun FileItemRow( + item: FileEntry, + textColor: Color, + secondaryText: Color, + dividerColor: Color, + isDarkTheme: Boolean, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (item.isImage) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(item.file) + .crossfade(true) + .size(84) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(6.dp)) + ) + } else { + FileExtensionBadge(extension = item.extension) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + color = textColor, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row { + Text(text = item.sizeFormatted, color = secondaryText, fontSize = 13.sp) + if (item.dateFormatted.isNotEmpty()) { + Text(text = " Β· ${item.dateFormatted}", color = secondaryText, fontSize = 13.sp) + } + } + } + } + Divider( + thickness = 0.5.dp, + color = dividerColor, + modifier = Modifier.padding(start = 74.dp) + ) + } +} + +/** Colored extension badge (like Telegram's file type indicator) */ +@Composable +private fun FileExtensionBadge(extension: String) { + val badgeColor = getExtensionColor(extension) + val displayExt = extension.uppercase().take(4) + + Box( + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(6.dp)) + .background(badgeColor), + contentAlignment = Alignment.Center + ) { + if (displayExt.isNotEmpty()) { + Text( + text = displayExt, + color = Color.White, + fontSize = if (displayExt.length > 3) 9.sp else 11.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } else { + Icon( + painter = painterResource(R.drawable.files_document), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } +} + +// ═══════════════════════════════════════════════════════════ +// Helper functions +// ═══════════════════════════════════════════════════════════ + +private fun getExtensionColor(ext: String): Color = when (ext.lowercase()) { + "pdf" -> Color(0xFFE53935) + "doc", "docx" -> Color(0xFF1E88E5) + "xls", "xlsx" -> Color(0xFF43A047) + "ppt", "pptx" -> Color(0xFFFF7043) + "zip", "rar", "7z", "tar", "gz" -> Color(0xFF8E24AA) + "mp3", "wav", "flac", "aac", "ogg", "m4a" -> Color(0xFFEF6C00) + "mp4", "avi", "mkv", "mov", "webm" -> Color(0xFF5C6BC0) + "apk" -> Color(0xFF00897B) + "txt", "log" -> Color(0xFF546E7A) + "json", "xml", "html", "css", "js", "kt", "java" -> Color(0xFF00ACC1) + else -> FileGray +} + +private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif") +private val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.getDefault()) + +private fun formatFileDate(lastModified: Long): String { + if (lastModified <= 0) return "" + return dateFormat.format(Date(lastModified)) +} + +private fun fileToEntry(context: Context, file: File): FileEntry { + val ext = file.extension.lowercase() + return FileEntry( + file = file, + name = file.name, + sizeFormatted = if (file.isDirectory) "" else Formatter.formatFileSize(context, file.length()), + dateFormatted = formatFileDate(file.lastModified()), + isDirectory = file.isDirectory, + extension = ext, + isImage = ext in imageExtensions + ) +} + +/** Load recent files from Downloads and common directories */ +private fun loadRecentFiles(context: Context): List { + val result = mutableListOf() + + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (downloads.exists() && downloads.isDirectory) { + scanDirRecursive(downloads, result, depth = 2) + } + + val documents = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + if (documents.exists() && documents.isDirectory) { + scanDirRecursive(documents, result, depth = 2) + } + + val rosettaDir = File(downloads, "Rosetta") + if (rosettaDir.exists() && rosettaDir.isDirectory) { + scanDirRecursive(rosettaDir, result, depth = 3) + } + + return result + .filter { it.isFile && it.length() > 0 } + .sortedByDescending { it.lastModified() } + .take(50) + .map { fileToEntry(context, it) } +} + +private fun scanDirRecursive(dir: File, result: MutableList, depth: Int) { + if (depth <= 0) return + val files = dir.listFiles() ?: return + for (f in files) { + if (f.name.startsWith(".")) continue + if (f.isFile) result.add(f) + else if (f.isDirectory && depth > 1) scanDirRecursive(f, result, depth - 1) + } +} + +/** List contents of a directory (folders first, then files by name) */ +private fun listDirectoryContents(context: Context, dir: File): List { + val files = dir.listFiles() ?: return emptyList() + return files + .filter { !it.name.startsWith(".") } + .map { fileToEntry(context, it) } + .sortedWith( + compareByDescending { it.isDirectory } + .thenBy { it.name.lowercase() } + ) +} + +/** Get storage info string (e.g. "12.5 GB free of 64 GB") */ +private fun getStorageInfo(context: Context): String = try { + val stat = android.os.StatFs(Environment.getExternalStorageDirectory().path) + val free = stat.availableBytes + val total = stat.totalBytes + "${Formatter.formatFileSize(context, free)} free of ${Formatter.formatFileSize(context, total)}" +} catch (_: Exception) { + "Internal storage" +} + +/** Toggle selection for gallery-as-file mode */ +private fun toggleGallerySelection( + currentOrder: List, + itemId: Long, + maxSelection: Int +): List { + return if (currentOrder.contains(itemId)) { + currentOrder - itemId + } else { + if (currentOrder.size >= maxSelection) currentOrder + else currentOrder + itemId + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index b860807..0af1893 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -134,6 +134,8 @@ fun ChatAttachAlert( onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null, onOpenCamera: () -> Unit = {}, onOpenFilePicker: () -> Unit = {}, + onGalleryFilesSelected: (List) -> Unit = {}, + onFileSelected: (android.net.Uri, String, Long) -> Unit = { _, _, _ -> }, onAvatarClick: () -> Unit = {}, currentUserPublicKey: String = "", maxSelection: Int = 10, @@ -871,9 +873,16 @@ fun ChatAttachAlert( modifier = Modifier.fillMaxSize() ) AttachAlertTab.FILE -> AttachAlertFileLayout( - onOpenFilePicker = { + onFileSelected = { uri, name, size -> + requestClose { onFileSelected(uri, name, size) } + }, + onGalleryFilesSelected = { uris -> + requestClose { onGalleryFilesSelected(uris) } + }, + onOpenSystemPicker = { requestClose { onOpenFilePicker() } }, + galleryMediaItems = state.mediaItems, isDarkTheme = isDarkTheme, modifier = Modifier.fillMaxSize() ) 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 251c410..a267a20 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 @@ -1438,13 +1438,24 @@ fun FileAttachment( if (savedFile.exists()) DownloadStatus.DOWNLOADED else DownloadStatus.NOT_DOWNLOADED } else { - DownloadStatus.DOWNLOADED + // Для ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½Π½Ρ‹Ρ… Ρ„Π°ΠΉΠ»ΠΎΠ² Π±Π΅Π· Ρ‚Π΅Π³Π° β€” провСряСм Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ Ρ„Π°ΠΉΠ» + if (savedFile.exists()) DownloadStatus.DOWNLOADED + else DownloadStatus.DOWNLOADED // blob Π΅ΡΡ‚ΡŒ Π² памяти } } // ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Ρ„Π°ΠΉΠ» Ρ‡Π΅Ρ€Π΅Π· систСмноС ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ - val openFile: () -> Unit = { + val openFile: () -> Unit = openFile@{ try { + if (!savedFile.exists()) { + // Π€Π°ΠΉΠ» Π΅Ρ‰Ρ‘ Π½Π΅ скачан β€” ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Π΅ΠΌ статус Ρ‡Ρ‚ΠΎΠ±Ρ‹ UI ΠΏΠΎΠΊΠ°Π·Π°Π» ΠΊΠ½ΠΎΠΏΠΊΡƒ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ + if (downloadTag.isNotEmpty()) { + downloadStatus = DownloadStatus.NOT_DOWNLOADED + } else { + android.widget.Toast.makeText(context, "File not available", android.widget.Toast.LENGTH_SHORT).show() + } + return@openFile + } val uri = FileProvider.getUriForFile( context, "${context.packageName}.provider", @@ -1458,7 +1469,9 @@ fun FileAttachment( addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(intent) - } catch (_: Exception) {} + } catch (e: Exception) { + android.widget.Toast.makeText(context, "Cannot open file: ${e.message}", android.widget.Toast.LENGTH_SHORT).show() + } } val download: () -> Unit = { diff --git a/app/src/main/res/drawable/files_document.xml b/app/src/main/res/drawable/files_document.xml new file mode 100644 index 0000000..a545ed5 --- /dev/null +++ b/app/src/main/res/drawable/files_document.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/files_folder.xml b/app/src/main/res/drawable/files_folder.xml new file mode 100644 index 0000000..5e3bcd7 --- /dev/null +++ b/app/src/main/res/drawable/files_folder.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/files_gallery.xml b/app/src/main/res/drawable/files_gallery.xml new file mode 100644 index 0000000..0583036 --- /dev/null +++ b/app/src/main/res/drawable/files_gallery.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/files_storage.xml b/app/src/main/res/drawable/files_storage.xml new file mode 100644 index 0000000..3ac36b5 --- /dev/null +++ b/app/src/main/res/drawable/files_storage.xml @@ -0,0 +1,10 @@ + + + +