feat: Implement file selection and gallery-as-file functionality in attachment UI
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,34 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📄 File picker launcher
|
// <EFBFBD>️ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// <20>📄 File picker launcher
|
||||||
val filePickerLauncher =
|
val filePickerLauncher =
|
||||||
rememberLauncherForActivityResult(
|
rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent()
|
contract = ActivityResultContracts.GetContent()
|
||||||
@@ -2334,6 +2361,46 @@ fun ChatDetailScreen(
|
|||||||
onOpenFilePicker = {
|
onOpenFilePicker = {
|
||||||
filePickerLauncher.launch("*/*")
|
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 = {
|
onAvatarClick = {
|
||||||
viewModel.sendAvatarMessage()
|
viewModel.sendAvatarMessage()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3202,6 +3202,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
// 💾 Сохраняем файл локально чтобы отправитель мог его открыть
|
||||||
|
try {
|
||||||
|
val app = getApplication<android.app.Application>()
|
||||||
|
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 encryptResult = MessageCrypto.encryptForSending(text, recipient)
|
||||||
val encryptedContent = encryptResult.ciphertext
|
val encryptedContent = encryptResult.ciphertext
|
||||||
val encryptedKey = encryptResult.encryptedKey
|
val encryptedKey = encryptResult.encryptedKey
|
||||||
|
|||||||
@@ -1,77 +1,817 @@
|
|||||||
package com.rosetta.messenger.ui.chats.attach
|
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.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.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.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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 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.
|
* Telegram‑style file browser tab.
|
||||||
* Phase 1: Styled button that launches the system file picker.
|
*
|
||||||
|
* @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
|
@Composable
|
||||||
internal fun AttachAlertFileLayout(
|
internal fun AttachAlertFileLayout(
|
||||||
onOpenFilePicker: () -> Unit,
|
onFileSelected: (Uri, String, Long) -> Unit,
|
||||||
|
onGalleryFilesSelected: (List<Uri>) -> Unit,
|
||||||
|
onOpenSystemPicker: () -> Unit,
|
||||||
|
galleryMediaItems: List<MediaItem>,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val context = LocalContext.current
|
||||||
val secondaryTextColor = Color(0xFF8E8E93)
|
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(
|
// ── Navigation state ──
|
||||||
modifier = modifier.fillMaxWidth(),
|
var currentDir by remember { mutableStateOf<File?>(null) }
|
||||||
contentAlignment = Alignment.Center
|
var currentTitle by remember { mutableStateOf("Select File") }
|
||||||
|
var history by remember { mutableStateOf(listOf<HistoryEntry>()) }
|
||||||
|
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<List<Long>>(emptyList()) }
|
||||||
|
|
||||||
|
// ── Recent files (loaded once) ──
|
||||||
|
var recentFiles by remember { mutableStateOf<List<FileEntry>>(emptyList()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
recentFiles = withContext(Dispatchers.IO) { loadRecentFiles(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Directory contents (loaded when currentDir changes) ──
|
||||||
|
var dirContents by remember { mutableStateOf<List<FileEntry>>(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(
|
// ── Top bar ──
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
val displayTitle = when {
|
||||||
modifier = Modifier.padding(32.dp)
|
showGallery -> if (gallerySelectedOrder.isNotEmpty())
|
||||||
) {
|
"${gallerySelectedOrder.size} selected" else "Gallery"
|
||||||
Icon(
|
else -> currentTitle
|
||||||
painter = TelegramIcons.File,
|
}
|
||||||
contentDescription = null,
|
FilePickerTopBar(
|
||||||
tint = PrimaryBlue,
|
title = displayTitle,
|
||||||
modifier = Modifier.size(72.dp)
|
showBack = showGallery || !isAtRoot,
|
||||||
)
|
isSearching = isSearching,
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
searchQuery = searchQuery,
|
||||||
Text(
|
onBackClick = {
|
||||||
text = "Send a File",
|
if (showGallery) {
|
||||||
color = textColor,
|
showGallery = false
|
||||||
fontSize = 20.sp,
|
gallerySelectedOrder = emptyList()
|
||||||
fontWeight = FontWeight.SemiBold
|
} else {
|
||||||
)
|
navigateBack()
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
}
|
||||||
Text(
|
},
|
||||||
text = "Browse files on your device and share them in the chat",
|
onSearchToggle = { isSearching = !isSearching; if (!isSearching) searchQuery = "" },
|
||||||
color = secondaryTextColor,
|
onSearchQueryChange = { searchQuery = it },
|
||||||
fontSize = 14.sp,
|
textColor = textColor,
|
||||||
textAlign = TextAlign.Center
|
bgColor = bgColor
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(28.dp))
|
|
||||||
Button(
|
Divider(thickness = 0.5.dp, color = dividerColor)
|
||||||
onClick = onOpenFilePicker,
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
// ── Content ──
|
||||||
shape = RoundedCornerShape(12.dp),
|
if (showGallery) {
|
||||||
modifier = Modifier
|
// ── Gallery mode: media grid for sending photos as files ──
|
||||||
.fillMaxWidth()
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
.height(48.dp)
|
if (galleryMediaItems.isEmpty()) {
|
||||||
.padding(horizontal = 24.dp)
|
Box(
|
||||||
) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
Text(
|
contentAlignment = Alignment.Center
|
||||||
text = "Choose File",
|
) {
|
||||||
color = Color.White,
|
Text("No photos or videos", color = secondaryText, fontSize = 15.sp)
|
||||||
fontSize = 16.sp,
|
}
|
||||||
fontWeight = FontWeight.Medium
|
} 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<FileEntry> {
|
||||||
|
val result = mutableListOf<File>()
|
||||||
|
|
||||||
|
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<File>, 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<FileEntry> {
|
||||||
|
val files = dir.listFiles() ?: return emptyList()
|
||||||
|
return files
|
||||||
|
.filter { !it.name.startsWith(".") }
|
||||||
|
.map { fileToEntry(context, it) }
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<FileEntry> { 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<Long>,
|
||||||
|
itemId: Long,
|
||||||
|
maxSelection: Int
|
||||||
|
): List<Long> {
|
||||||
|
return if (currentOrder.contains(itemId)) {
|
||||||
|
currentOrder - itemId
|
||||||
|
} else {
|
||||||
|
if (currentOrder.size >= maxSelection) currentOrder
|
||||||
|
else currentOrder + itemId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ fun ChatAttachAlert(
|
|||||||
onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null,
|
onMediaSelectedWithCaption: ((MediaItem, String) -> Unit)? = null,
|
||||||
onOpenCamera: () -> Unit = {},
|
onOpenCamera: () -> Unit = {},
|
||||||
onOpenFilePicker: () -> Unit = {},
|
onOpenFilePicker: () -> Unit = {},
|
||||||
|
onGalleryFilesSelected: (List<android.net.Uri>) -> Unit = {},
|
||||||
|
onFileSelected: (android.net.Uri, String, Long) -> Unit = { _, _, _ -> },
|
||||||
onAvatarClick: () -> Unit = {},
|
onAvatarClick: () -> Unit = {},
|
||||||
currentUserPublicKey: String = "",
|
currentUserPublicKey: String = "",
|
||||||
maxSelection: Int = 10,
|
maxSelection: Int = 10,
|
||||||
@@ -871,9 +873,16 @@ fun ChatAttachAlert(
|
|||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
AttachAlertTab.FILE -> AttachAlertFileLayout(
|
AttachAlertTab.FILE -> AttachAlertFileLayout(
|
||||||
onOpenFilePicker = {
|
onFileSelected = { uri, name, size ->
|
||||||
|
requestClose { onFileSelected(uri, name, size) }
|
||||||
|
},
|
||||||
|
onGalleryFilesSelected = { uris ->
|
||||||
|
requestClose { onGalleryFilesSelected(uris) }
|
||||||
|
},
|
||||||
|
onOpenSystemPicker = {
|
||||||
requestClose { onOpenFilePicker() }
|
requestClose { onOpenFilePicker() }
|
||||||
},
|
},
|
||||||
|
galleryMediaItems = state.mediaItems,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1438,13 +1438,24 @@ fun FileAttachment(
|
|||||||
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
else DownloadStatus.NOT_DOWNLOADED
|
else DownloadStatus.NOT_DOWNLOADED
|
||||||
} else {
|
} else {
|
||||||
DownloadStatus.DOWNLOADED
|
// Для отправленных файлов без тега — проверяем локальный файл
|
||||||
|
if (savedFile.exists()) DownloadStatus.DOWNLOADED
|
||||||
|
else DownloadStatus.DOWNLOADED // blob есть в памяти
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Открыть файл через системное приложение
|
// Открыть файл через системное приложение
|
||||||
val openFile: () -> Unit = {
|
val openFile: () -> Unit = openFile@{
|
||||||
try {
|
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(
|
val uri = FileProvider.getUriForFile(
|
||||||
context,
|
context,
|
||||||
"${context.packageName}.provider",
|
"${context.packageName}.provider",
|
||||||
@@ -1458,7 +1469,9 @@ fun FileAttachment(
|
|||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
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 = {
|
val download: () -> Unit = {
|
||||||
|
|||||||
10
app/src/main/res/drawable/files_document.xml
Normal file
10
app/src/main/res/drawable/files_document.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Document / file icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M14,2H6c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8l-6,-6zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/files_folder.xml
Normal file
10
app/src/main/res/drawable/files_folder.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Folder icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/files_gallery.xml
Normal file
10
app/src/main/res/drawable/files_gallery.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Gallery / image icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/files_storage.xml
Normal file
10
app/src/main/res/drawable/files_storage.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- SD card / internal storage icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M18,2h-8L4,8v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM12,18c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,8H6V6h9v2z" />
|
||||||
|
</vector>
|
||||||
Reference in New Issue
Block a user