c60eb8d27b
Build & Release APK / build (push) Has been cancelled
- 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>
684 lines
36 KiB
Kotlin
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"
|
|
}
|