package com.syncflow.ui.files import android.content.ClipData import android.content.Intent import android.net.Uri import android.webkit.MimeTypeMap import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape 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.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext 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.data.db.entities.SyncFileStateEntity import kotlinx.coroutines.launch import java.io.File import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun FilesScreen( modifier: Modifier = Modifier, vm: FilesViewModel = hiltViewModel(), ) { val pairs by vm.pairs.collectAsState() val selectedPair by vm.selectedPair.collectAsState() val files by vm.files.collectAsState() val isDownloading by vm.isDownloading.collectAsState() val selectedKeys by vm.selectedKeys.collectAsState() val isSelectionMode = selectedKeys.isNotEmpty() val selectedCount = selectedKeys.size val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() var showDeleteSelectedDialog by remember { mutableStateOf(false) } BackHandler(enabled = isSelectionMode) { vm.clearSelection() } LaunchedEffect(Unit) { vm.fileAction.collect { action -> when (action) { is FileAction.Open -> { try { val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", action.file ) val mimeType = action.file.name.mimeType() val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, mimeType) // ClipData is required so FLAG_GRANT_READ_URI_PERMISSION // propagates to whichever app the system chooser picks. clipData = ClipData.newRawUri("", uri) addFlags( Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK ) } context.startActivity(intent) } catch (e: Exception) { snackbarHostState.showSnackbar("Cannot open file: ${e.message}") } } is FileAction.Share -> { try { val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", action.file ) val intent = 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) } context.startActivity( Intent.createChooser(intent, "Share via").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } ) } catch (e: Exception) { snackbarHostState.showSnackbar("Cannot share file: ${e.message}") } } is FileAction.ShareMultiple -> { try { val uris = action.files.map { file -> FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } val intent = 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) } context.startActivity( Intent.createChooser(intent, "Share files").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } ) } catch (e: Exception) { snackbarHostState.showSnackbar("Cannot share: ${e.message}") } } is FileAction.Error -> scope.launch { snackbarHostState.showSnackbar(action.message) } } } } if (showDeleteSelectedDialog) { AlertDialog( onDismissRequest = { showDeleteSelectedDialog = false }, icon = { Icon(Icons.Default.Delete, contentDescription = null) }, title = { Text("Delete $selectedCount file${if (selectedCount != 1) "s" else ""}?") }, text = { Text("Selected files will be removed from this device.") }, confirmButton = { TextButton(onClick = { vm.deleteSelected() showDeleteSelectedDialog = false }) { Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteSelectedDialog = false }) { Text("Cancel") } }, ) } Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { // Selection toolbar if (isSelectionMode) { Surface( color = MaterialTheme.colorScheme.primaryContainer, tonalElevation = 3.dp, ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = { vm.clearSelection() }) { Icon(Icons.Default.Close, contentDescription = "Clear selection") } Text( "$selectedCount selected", style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f), ) IconButton(onClick = { vm.shareSelected() }) { Icon(Icons.Default.Share, contentDescription = "Share selected") } IconButton(onClick = { showDeleteSelectedDialog = true }) { Icon( Icons.Default.Delete, contentDescription = "Delete selected", tint = MaterialTheme.colorScheme.error, ) } } } HorizontalDivider() } // Pair tabs if (pairs.size > 1 && !isSelectionMode) { ScrollableTabRow( selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0), edgePadding = 16.dp, containerColor = MaterialTheme.colorScheme.surface, divider = {}, ) { pairs.forEach { pair -> Tab( selected = pair.id == selectedPair?.id, onClick = { vm.selectPair(pair.id) }, text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, ) } } HorizontalDivider() } when { pairs.isEmpty() -> FilesEmptyState( icon = Icons.Default.FolderOpen, title = "No sync pairs yet", subtitle = "Create a sync pair to browse its files", ) files.isEmpty() -> FilesEmptyState( icon = Icons.Default.FolderOpen, title = "No synced files yet", subtitle = "Run a sync to populate this view", ) else -> { if (!isSelectionMode) { Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( "${files.size} file${if (files.size != 1) "s" else ""}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( files.sumOf { it.localSizeBytes }.toDisplaySize(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } LazyColumn( contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(0.dp), ) { val grouped = files.groupBy { f -> val idx = f.relativePath.indexOf('/') if (idx < 0) "" else f.relativePath.substring(0, idx) } grouped.forEach { (dir, dirFiles) -> if (dir.isNotEmpty() && !isSelectionMode) { item(key = "dir_$dir") { Row( modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.width(6.dp)) Text( dir, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) } } } items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file -> FileRow( file = file, isInSubDir = dir.isNotEmpty() && !isSelectionMode, isSelectionMode = isSelectionMode, isSelected = vm.fileKey(file) in selectedKeys, vm = vm, ) HorizontalDivider( color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), modifier = Modifier.padding(start = if (dir.isNotEmpty() && !isSelectionMode) 38.dp else 16.dp), ) } } item { Spacer(Modifier.height(80.dp)) } } } } } // Download progress if (isDownloading) { Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter), ) { Surface( color = MaterialTheme.colorScheme.surfaceVariant, tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth(), ) { Row( modifier = Modifier .fillMaxWidth() .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("Downloading for preview…", style = MaterialTheme.typography.bodySmall) } } } } SnackbarHost( hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter), ) } } @Composable private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( icon, contentDescription = null, modifier = Modifier.size(72.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), ) Text(title, style = MaterialTheme.typography.titleMedium) Text( subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun FileRow( file: SyncFileStateEntity, isInSubDir: Boolean, isSelectionMode: Boolean, isSelected: Boolean, vm: FilesViewModel, ) { val name = file.relativePath.substringAfterLast('/') val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) } var menuExpanded by remember { mutableStateOf(false) } var showRenameDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } if (showRenameDialog) { RenameDialog( currentName = name, onConfirm = { newName -> vm.renameFile(file, newName); showRenameDialog = false }, onDismiss = { showRenameDialog = false }, ) } if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, icon = { Icon(Icons.Default.Delete, contentDescription = null) }, title = { Text("Delete file?") }, text = { Text("\"$name\" will be removed from this device.") }, confirmButton = { TextButton(onClick = { vm.deleteFile(file); showDeleteDialog = false }) { Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") } }, ) } Surface( color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { if (isSelectionMode) vm.toggleSelection(file) else menuExpanded = true }, onLongClick = { vm.toggleSelection(file) }, ), ) { Row( modifier = Modifier.padding( start = if (isInSubDir) 22.dp else 0.dp, top = 10.dp, bottom = 10.dp, end = 0.dp, ), verticalAlignment = Alignment.CenterVertically, ) { if (isSelectionMode) { Checkbox( checked = isSelected, onCheckedChange = { vm.toggleSelection(file) }, modifier = Modifier.padding(horizontal = 4.dp), ) } else { Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.size(32.dp), ) { Box(contentAlignment = Alignment.Center) { Icon( fileIcon(name), contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer, ) } } Spacer(Modifier.width(12.dp)) } Column(modifier = Modifier.weight(1f)) { Text( name, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( "Synced $syncedAt · ${file.localSizeBytes.toDisplaySize()}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (!isSelectionMode) { Box { IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) { Icon( Icons.Default.MoreVert, contentDescription = "File options", modifier = 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; vm.openFile(file) }, ) DropdownMenuItem( text = { Text("Share") }, leadingIcon = { Icon(Icons.Default.Share, null) }, onClick = { menuExpanded = false; vm.shareFile(file) }, ) HorizontalDivider() DropdownMenuItem( text = { Text("Rename") }, leadingIcon = { Icon(Icons.Default.Edit, null) }, onClick = { menuExpanded = false; showRenameDialog = true }, ) DropdownMenuItem( text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, leadingIcon = { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }, onClick = { menuExpanded = false; showDeleteDialog = true }, ) } } } } } } @Composable private fun RenameDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) { var newName by remember { mutableStateOf(currentName) } AlertDialog( onDismissRequest = onDismiss, icon = { Icon(Icons.Default.Edit, contentDescription = null) }, title = { Text("Rename file") }, text = { OutlinedTextField( value = newName, onValueChange = { newName = it }, label = { Text("New name") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) }, confirmButton = { TextButton( onClick = { val trimmed = newName.trim() if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) else onDismiss() }, enabled = newName.isNotBlank(), ) { Text("Rename") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } private fun String.mimeType(): String { val ext = substringAfterLast('.', "").lowercase() return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*" } private fun fileIcon(name: String) = 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" }