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.CAMERA" />
|
||||
<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_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 =
|
||||
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()
|
||||
},
|
||||
|
||||
@@ -3202,6 +3202,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
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 encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
|
||||
@@ -1,72 +1,265 @@
|
||||
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<Uri>) -> Unit,
|
||||
onOpenSystemPicker: () -> Unit,
|
||||
galleryMediaItems: List<MediaItem>,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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 secondaryTextColor = Color(0xFF8E8E93)
|
||||
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)
|
||||
|
||||
// ── Navigation state ──
|
||||
var currentDir by remember { mutableStateOf<File?>(null) }
|
||||
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)
|
||||
) {
|
||||
// ── 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.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.File,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(72.dp)
|
||||
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
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "Send a File",
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
},
|
||||
onItemCheckClick = { item ->
|
||||
gallerySelectedOrder = toggleGallerySelection(
|
||||
gallerySelectedOrder, item.id, 10
|
||||
)
|
||||
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
|
||||
},
|
||||
onItemLongClick = { item ->
|
||||
gallerySelectedOrder = toggleGallerySelection(
|
||||
gallerySelectedOrder, item.id, 10
|
||||
)
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Send button when items selected
|
||||
if (gallerySelectedOrder.isNotEmpty()) {
|
||||
val count = gallerySelectedOrder.size
|
||||
Button(
|
||||
onClick = onOpenFilePicker,
|
||||
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)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Choose File",
|
||||
text = if (count == 1) "Send 1 file" else "Send $count files",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
@@ -75,3 +268,550 @@ internal fun AttachAlertFileLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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,
|
||||
onOpenCamera: () -> Unit = {},
|
||||
onOpenFilePicker: () -> Unit = {},
|
||||
onGalleryFilesSelected: (List<android.net.Uri>) -> 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()
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
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