diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt index a1d0369..1ce6910 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt @@ -2,519 +2,661 @@ 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.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.data.db.entities.SyncFileStateEntity +import com.syncflow.ui.browser.RemoteBrowserViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +private val STORAGE_ROOT = File("/storage/emulated/0") + +@OptIn(ExperimentalMaterial3Api::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 + var activeTab by remember { mutableStateOf(0) } 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.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 - ) - val intent = Intent(Intent.ACTION_SEND).apply { + 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) - } - context.startActivity( - Intent.createChooser(intent, "Share via").apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - ) - } catch (e: Exception) { - snackbarHostState.showSnackbar("Cannot share file: ${e.message}") - } + }, "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 { file -> - FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) - } - val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + 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) - } - 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) + }, "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) } } } } - 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, + + // ── 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(Icons.Default.PhoneAndroid, null, Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Phone") + } + SegmentedButton( + selected = activeTab == 1, + onClick = { activeTab = 1 }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { + Icon(Icons.Default.Cloud, null, Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Cloud") + } + } + HorizontalDivider() + + when (activeTab) { + 0 -> LocalExplorer(modifier = Modifier.weight(1f)) + 1 -> CloudExplorer(vm = vm, 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 = 4.dp, vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.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), + 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 = { vm.shareSelected() }) { - Icon(Icons.Default.Share, contentDescription = "Share selected") - } - IconButton(onClick = { showDeleteSelectedDialog = true }) { - Icon( - Icons.Default.Delete, contentDescription = "Delete selected", - tint = MaterialTheme.colorScheme.error, - ) + IconButton(onClick = { searchActive = false; searchQuery = "" }) { + Icon(Icons.Default.Close, null) } } - } - 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, - ) + } 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() - 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, - ) + // ── 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) + } } } } - 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), - ) - } + 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)) } - item { Spacer(Modifier.height(80.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)) } } } } - // Download progress - if (isDownloading) { - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), + SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) + } +} + +// ── Cloud file explorer ─────────────────────────────────────────────────────── + +@Composable +private fun CloudExplorer(vm: FilesViewModel, modifier: Modifier = Modifier) { + val accounts by vm.accounts.collectAsState() + var selectedId by remember { mutableStateOf(-1L) } + 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(accounts) { + if (selectedId == -1L && accounts.isNotEmpty()) { + selectedId = accounts.first().id + cloudVm.init(selectedId, "/") + } + } + + 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), ) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - tonalElevation = 4.dp, - modifier = Modifier.fillMaxWidth(), + items(accounts) { acct -> + FilterChip( + selected = acct.id == selectedId, + onClick = { selectedId = 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, ) { - 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) + 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() - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter), - ) + // ── 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(selectedId, 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 FilesEmptyState(icon: ImageVector, title: String, subtitle: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), +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( - icon, contentDescription = null, - modifier = Modifier.size(72.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + 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), ) - Text(title, style = MaterialTheme.typography.titleMedium) + } + 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( - subtitle, + if (entry.isDir) "${entry.childCount} item${if (entry.childCount != 1) "s" else ""}" + else entry.sizeBytes.toDisplaySize(), 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) + 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) } - }, - 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 }, - ) - } + 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 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(), +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), ) - }, - 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") } }, - ) + } + 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) = when { +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(".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(".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(".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(".gz", ignoreCase = true) -> Icons.Default.FolderZip name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet else -> Icons.Default.InsertDriveFile } diff --git a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt index bafe9bf..95252e6 100644 --- a/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/files/FilesViewModel.kt @@ -40,6 +40,9 @@ class FilesViewModel @Inject constructor( val pairs: StateFlow> = syncPairDao.observeAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val accounts = accountRepository.observeAll() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + private val _selectedPairId = MutableStateFlow(null) val selectedPair: StateFlow = combine(_selectedPairId, pairs) { id, list -> @@ -158,6 +161,31 @@ class FilesViewModel @Inject constructor( fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}" + fun openCloudFile(accountId: Long, remotePath: String) { + viewModelScope.launch { + val account = accountRepository.getAccount(accountId) ?: run { + _fileAction.emit(FileAction.Error("Account not found")) + return@launch + } + val provider = providerFactory.create(account) + val fileName = remotePath.substringAfterLast('/') + val cacheFile = File(context.cacheDir, "syncflow_open/$fileName") + cacheFile.parentFile?.mkdirs() + _isDownloading.value = true + try { + cacheFile.outputStream().use { out -> + provider.downloadFile(remotePath, out) { }.getOrThrow() + } + _fileAction.emit(FileAction.Open(cacheFile)) + } catch (e: Exception) { + Timber.e(e, "Cloud open failed: $remotePath") + _fileAction.emit(FileAction.Error("Cannot open: ${e.message}")) + } finally { + _isDownloading.value = false + } + } + } + // ── Download-then-open/share ────────────────────────────────────────────── private fun downloadAndOpen(file: SyncFileStateEntity) { diff --git a/version.properties b/version.properties index 64691a0..2ea449a 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.57 -VERSION_CODE=58 +VERSION_NAME=1.0.58 +VERSION_CODE=59