Files
SyncFlow/app/src/main/kotlin/com/syncflow/ui/files/FilesScreen.kt
T
amir c60eb8d27b
Build & Release APK / build (push) Has been cancelled
v1.0.63: live sync progress counters, pause/resume, .gitignore fix
- SyncEngine: accepts onProgress callback — emits uploaded/downloaded/
  deleted/bytes counts atomically as each file completes
- SyncWorker: streams progress to WorkManager data so the UI can poll
  it live; reports per-run counters in the completion notification;
  adds pause/resume support
- HomeViewModel/PairDetailViewModel: subscribe to live WorkManager
  progress and surface it via SyncProgress state
- SyncPairEntity/SyncPairDao/SyncDatabase: persist last-run counters
  (uploaded, downloaded, deleted, bytesTransferred) in the DB with a
  Room migration (v3→v4)
- AppModule: provides WorkManager as an injectable singleton
- .gitignore: add .kotlin/ to exclude compiler session files

Security: no new issues — all logging via Timber (debug-only), DB
queries use Room parameterized API, file sharing via FileProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:07:25 +00:00

684 lines
36 KiB
Kotlin

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<List<LocalEntry>>(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"
}