package com.syncflow.ui.files import android.content.ClipData import android.content.Intent import android.webkit.MimeTypeMap import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.ui.browser.RemoteBrowserViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File private val STORAGE_ROOT = File("/storage/emulated/0") @OptIn(ExperimentalMaterial3Api::class) @Composable fun FilesScreen( modifier: Modifier = Modifier, vm: FilesViewModel = hiltViewModel(), ) { var activeTab by remember { mutableStateOf(0) } val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val accounts by vm.accounts.collectAsState() var selectedAccountId by remember { mutableStateOf(-1L) } LaunchedEffect(accounts) { if (selectedAccountId == -1L && accounts.isNotEmpty()) selectedAccountId = accounts.first().id } val cloudLabel = accounts.find { it.id == selectedAccountId }?.displayName ?: accounts.firstOrNull()?.displayName ?: "Cloud" LaunchedEffect(Unit) { vm.fileAction.collect { action -> when (action) { is FileAction.Open -> try { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", action.file) context.startActivity(Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, action.file.name.mimeType()) clipData = ClipData.newRawUri("", uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("Cannot open: ${e.message}") } } is FileAction.Share -> try { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", action.file) context.startActivity( Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = action.file.name.mimeType() putExtra(Intent.EXTRA_STREAM, uri) clipData = ClipData.newRawUri("", uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) }, "Share").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } ) } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("Cannot share: ${e.message}") } } is FileAction.ShareMultiple -> try { val uris = action.files.map { FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it) } context.startActivity( Intent.createChooser(Intent(Intent.ACTION_SEND_MULTIPLE).apply { type = "*/*" putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) }, "Share files").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } ) } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("Cannot share: ${e.message}") } } is FileAction.Error -> scope.launch { snackbarHostState.showSnackbar(action.message) } } } } Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { // ── Phone / Cloud toggle ────────────────────────────────────────── SingleChoiceSegmentedButtonRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { SegmentedButton( selected = activeTab == 0, onClick = { activeTab = 0 }, shape = SegmentedButtonDefaults.itemShape(0, 2), icon = { SegmentedButtonDefaults.Icon(active = activeTab == 0, activeContent = { Icon(Icons.Default.PhoneAndroid, null, Modifier.size(16.dp)) }) }, ) { Text("Phone") } SegmentedButton( selected = activeTab == 1, onClick = { activeTab = 1 }, shape = SegmentedButtonDefaults.itemShape(1, 2), icon = { SegmentedButtonDefaults.Icon(active = activeTab == 1, activeContent = { Icon(Icons.Default.Cloud, null, Modifier.size(16.dp)) }) }, ) { Text(cloudLabel, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) } } HorizontalDivider() when (activeTab) { 0 -> LocalExplorer(modifier = Modifier.weight(1f)) 1 -> CloudExplorer( vm = vm, selectedAccountId = selectedAccountId, onAccountSelect = { selectedAccountId = it }, modifier = Modifier.weight(1f), ) } } val isDownloading by vm.isDownloading.collectAsState() if (isDownloading) { Surface( color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) Text("Opening file…", style = MaterialTheme.typography.bodySmall) } } } SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) } } // ── Local file explorer ─────────────────────────────────────────────────────── private data class LocalEntry(val file: File, val isDir: Boolean, val childCount: Int = 0, val sizeBytes: Long = 0L) @Composable private fun LocalExplorer(modifier: Modifier = Modifier) { val context = LocalContext.current val scope = rememberCoroutineScope() var currentPath by remember { mutableStateOf(STORAGE_ROOT) } var pathStack by remember { mutableStateOf(listOf(STORAGE_ROOT)) } var entries by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var searchQuery by remember { mutableStateOf("") } var searchActive by remember { mutableStateOf(false) } val breadcrumbState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } fun loadDir(dir: File) { isLoading = true entries = emptyList() scope.launch { val result = withContext(Dispatchers.IO) { (dir.listFiles() ?: emptyArray()) .filter { !it.name.startsWith(".") } .sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) .map { f -> if (f.isDirectory) LocalEntry(f, true, f.listFiles()?.size ?: 0) else LocalEntry(f, false, sizeBytes = f.length()) } } entries = result isLoading = false } } fun navigate(dir: File) { currentPath = dir; pathStack = pathStack + dir searchQuery = ""; searchActive = false; loadDir(dir) } fun navigateUp(): Boolean { if (pathStack.size <= 1) return false val newStack = pathStack.dropLast(1) pathStack = newStack; currentPath = newStack.last() searchQuery = ""; searchActive = false; loadDir(currentPath) return true } LaunchedEffect(Unit) { loadDir(currentPath) } BackHandler(enabled = pathStack.size > 1) { navigateUp() } val relParts = currentPath.absolutePath .removePrefix(STORAGE_ROOT.absolutePath).trimStart('/').split('/').filter { it.isNotEmpty() } LaunchedEffect(currentPath) { scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) } searchQuery = ""; searchActive = false } val filtered = if (searchQuery.isBlank()) entries else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { // ── Breadcrumbs / search bar ────────────────────────────────────── Surface(tonalElevation = 1.dp) { if (searchActive) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier.weight(1f).focusRequester(focusRequester), placeholder = { Text("Search in folder…") }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = {}), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), ) IconButton(onClick = { searchActive = false; searchQuery = "" }) { Icon(Icons.Default.Close, null) } } } else { LazyRow( state = breadcrumbState, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { item { BreadcrumbChip("📱 Storage", relParts.isEmpty()) { pathStack = listOf(STORAGE_ROOT); currentPath = STORAGE_ROOT; loadDir(STORAGE_ROOT) } } itemsIndexed(relParts) { idx, part -> Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) val partFile = File(STORAGE_ROOT, relParts.take(idx + 1).joinToString("/")) BreadcrumbChip(part, idx == relParts.lastIndex) { val i = pathStack.indexOfLast { it.absolutePath == partFile.absolutePath } pathStack = if (i >= 0) pathStack.take(i + 1) else listOf(partFile) currentPath = partFile; loadDir(partFile) } } item { Spacer(Modifier.width(4.dp)) IconButton(onClick = { searchActive = true }, modifier = Modifier.size(28.dp)) { Icon(Icons.Default.Search, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } } } } HorizontalDivider() // ── Content ─────────────────────────────────────────────────────── when { isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } filtered.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) Spacer(Modifier.height(12.dp)) Text( if (searchQuery.isBlank()) "Empty folder" else "No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } else -> LazyColumn(modifier = Modifier.fillMaxSize()) { if (currentPath.absolutePath == STORAGE_ROOT.absolutePath && searchQuery.isBlank()) { item { Text("Quick access", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 6.dp)) LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 8.dp)) { val shortcuts = listOf( Triple("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"), Triple("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"), Triple("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"), Triple("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"), Triple("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"), Triple("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"), ) items(shortcuts.filter { File(it.third).isDirectory }) { (label, icon, path) -> Surface(onClick = { navigate(File(path)) }, shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.height(72.dp).width(80.dp)) { Column(modifier = Modifier.fillMaxSize().padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Icon(icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer) Spacer(Modifier.height(4.dp)) Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1) } } } } HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Text("All files", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp)) } } items(filtered, key = { it.file.absolutePath }) { entry -> LocalFileItem( entry = entry, onClick = { if (entry.isDir) navigate(entry.file) else { try { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", entry.file) context.startActivity(Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, entry.file.name.mimeType()) clipData = ClipData.newRawUri("", uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("No app can open this file") } } } }, onShare = { try { val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", entry.file) context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply { type = entry.file.name.mimeType() putExtra(Intent.EXTRA_STREAM, uri) clipData = ClipData.newRawUri("", uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) }, "Share").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (_: Exception) {} }, ) } item { Spacer(Modifier.height(80.dp)) } } } } SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) } } // ── Cloud file explorer ─────────────────────────────────────────────────────── @Composable private fun CloudExplorer( vm: FilesViewModel, selectedAccountId: Long, onAccountSelect: (Long) -> Unit, modifier: Modifier = Modifier, ) { val accounts by vm.accounts.collectAsState() val cloudVm: RemoteBrowserViewModel = hiltViewModel(key = "cloud_explorer") val state by cloudVm.state.collectAsState() val breadcrumbState = rememberLazyListState() val scope = rememberCoroutineScope() var searchQuery by remember { mutableStateOf("") } var searchActive by remember { mutableStateOf(false) } LaunchedEffect(selectedAccountId) { if (selectedAccountId != -1L) cloudVm.init(selectedAccountId, "/") } LaunchedEffect(state.currentPath) { val segs = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() } scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segs.size)) } searchQuery = ""; searchActive = false } BackHandler(enabled = state.pathStack.size > 1) { cloudVm.navigateUp() } if (accounts.isEmpty()) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) Spacer(Modifier.height(12.dp)) Text("No cloud accounts", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("Add an account in Settings", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) } } return } val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() } val filtered = if (searchQuery.isBlank()) state.entries else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) } Column(modifier = modifier) { // ── Account chips ───────────────────────────────────────────────────── if (accounts.size > 1) { LazyRow( contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(accounts) { acct -> FilterChip( selected = acct.id == selectedAccountId, onClick = { onAccountSelect(acct.id); cloudVm.init(acct.id, "/") }, label = { Text(acct.displayName, maxLines = 1) }, leadingIcon = { Icon(Icons.Default.Cloud, null, Modifier.size(14.dp)) }, ) } } HorizontalDivider() } // ── Breadcrumbs / search ────────────────────────────────────────────── Surface(tonalElevation = 1.dp) { if (searchActive) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier.weight(1f).focusRequester(focusRequester), placeholder = { Text("Search in folder…") }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = {}), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), ) IconButton(onClick = { searchActive = false; searchQuery = "" }) { Icon(Icons.Default.Close, null) } } } else { LazyRow( state = breadcrumbState, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { item { BreadcrumbChip("☁ Root", segments.isEmpty()) { cloudVm.navigateToBreadcrumb("/") } } itemsIndexed(segments) { idx, seg -> Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) val segPath = "/" + segments.take(idx + 1).joinToString("/") BreadcrumbChip(seg, idx == segments.lastIndex) { cloudVm.navigateToBreadcrumb(segPath) } } item { Spacer(Modifier.width(4.dp)) IconButton(onClick = { searchActive = true }, modifier = Modifier.size(28.dp)) { Icon(Icons.Default.Search, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } } } } HorizontalDivider() // ── Content ─────────────────────────────────────────────────────────── Box(modifier = Modifier.weight(1f)) { when { state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } state.error != null -> Column( modifier = Modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) Spacer(Modifier.height(12.dp)) Text(state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(16.dp)) FilledTonalButton(onClick = cloudVm::retry) { Icon(Icons.Default.Refresh, null, Modifier.size(18.dp)) Spacer(Modifier.width(6.dp)) Text("Retry") } } filtered.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) Spacer(Modifier.height(12.dp)) Text( if (searchQuery.isBlank()) "Empty folder" else "No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } else -> LazyColumn(modifier = Modifier.fillMaxSize()) { items(filtered, key = { it.path }) { entry -> CloudFileItem( file = entry, onClick = { if (entry.isDirectory) cloudVm.navigateTo(entry.path) else vm.openCloudFile(selectedAccountId, entry.path) }, ) } item { Spacer(Modifier.height(80.dp)) } } } } } } // ── Shared UI components ────────────────────────────────────────────────────── @Composable private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) { if (isLast) { Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) { Text(label, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1) } } else { TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)) { Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1) } } } @Composable private fun LocalFileItem(entry: LocalEntry, onClick: () -> Unit, onShare: () -> Unit) { var menuExpanded by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), ) { Box( modifier = Modifier.size(46.dp).clip(RoundedCornerShape(12.dp)).background( if (entry.isDir) Color(0xFF1B5E20).copy(alpha = 0.12f) else Color(0xFF0D47A1).copy(alpha = 0.10f) ), contentAlignment = Alignment.Center, ) { Icon( imageVector = if (entry.isDir) Icons.Default.Folder else fileIcon(entry.file.name), contentDescription = null, modifier = Modifier.size(26.dp), tint = if (entry.isDir) Color(0xFF2E7D32) else Color(0xFF1565C0), ) } Column(modifier = Modifier.weight(1f)) { Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = if (entry.isDir) FontWeight.Medium else FontWeight.Normal) Text( if (entry.isDir) "${entry.childCount} item${if (entry.childCount != 1) "s" else ""}" else entry.sizeBytes.toDisplaySize(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (entry.isDir) { Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) } else { Box { IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) { Icon(Icons.Default.MoreVert, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { DropdownMenuItem(text = { Text("Open") }, leadingIcon = { Icon(Icons.Default.OpenInNew, null) }, onClick = { menuExpanded = false; onClick() }) DropdownMenuItem(text = { Text("Share") }, leadingIcon = { Icon(Icons.Default.Share, null) }, onClick = { menuExpanded = false; onShare() }) } } } } HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp) } @Composable private fun CloudFileItem(file: com.syncflow.domain.model.RemoteFile, onClick: () -> Unit) { Row( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), ) { Box( modifier = Modifier.size(46.dp).clip(RoundedCornerShape(12.dp)).background( if (file.isDirectory) Color(0xFF1B5E20).copy(alpha = 0.12f) else Color(0xFF0D47A1).copy(alpha = 0.10f) ), contentAlignment = Alignment.Center, ) { Icon( imageVector = if (file.isDirectory) Icons.Default.Folder else fileIcon(file.name), contentDescription = null, modifier = Modifier.size(26.dp), tint = if (file.isDirectory) Color(0xFF2E7D32) else Color(0xFF1565C0), ) } Column(modifier = Modifier.weight(1f)) { Text(file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = if (file.isDirectory) FontWeight.Medium else FontWeight.Normal) if (!file.isDirectory) { Text(file.sizeBytes.toDisplaySize(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } if (file.isDirectory) { Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) } else { Icon(Icons.Default.FileDownload, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) } } HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp) } // ── Helpers ─────────────────────────────────────────────────────────────────── private fun String.mimeType(): String { val ext = substringAfterLast('.', "").lowercase() return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*" } private fun fileIcon(name: String): ImageVector = when { name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf name.endsWith(".jpg", ignoreCase = true) || name.endsWith(".jpeg", ignoreCase = true) || name.endsWith(".png", ignoreCase = true) || name.endsWith(".gif", ignoreCase = true) || name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image name.endsWith(".mp4", ignoreCase = true) || name.endsWith(".mkv", ignoreCase = true) || name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile name.endsWith(".mp3", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) || name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile name.endsWith(".zip", ignoreCase = true) || name.endsWith(".tar", ignoreCase = true) || name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet else -> Icons.Default.InsertDriveFile } private fun Long.toDisplaySize(): String = when { this < 1_024 -> "$this B" this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB" this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB" else -> "${"%.1f".format(this / 1_073_741_824.0)} GB" }