f751b26a9e
- Add FileWatchService for real-time ON_CHANGE sync (FileObserver for direct paths, ContentObserver for SAF content:// URIs), 5s debounce - Fix remote browser stuck spinner: cancel in-flight jobs on navigation, reset entries immediately, add Retry button on error - Fix browser reuse bug: LaunchedEffect key now includes initialPath - Fix WebDavProvider: rethrow XML parse errors (no more silent Empty folder) and URL-decode file names from href - Notifications now use BigTextStyle showing per-file-type counts (Uploaded/Downloaded/Deleted) matching Autosync notification style - Wire FileWatchService into BootReceiver and HomeViewModel toggle - Register FileWatchService in AndroidManifest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
6.8 KiB
Kotlin
155 lines
6.8 KiB
Kotlin
package com.syncflow.ui.browser
|
|
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
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.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import com.syncflow.domain.model.RemoteFile
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun RemoteBrowserDialog(
|
|
accountId: Long,
|
|
initialPath: String = "/",
|
|
onSelect: (path: String) -> Unit,
|
|
onDismiss: () -> Unit,
|
|
vm: RemoteBrowserViewModel = hiltViewModel(),
|
|
) {
|
|
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
|
|
|
|
val state by vm.state.collectAsState()
|
|
|
|
Dialog(
|
|
onDismissRequest = onDismiss,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
|
) {
|
|
Surface(
|
|
modifier = Modifier
|
|
.fillMaxWidth(0.95f)
|
|
.fillMaxHeight(0.85f),
|
|
shape = MaterialTheme.shapes.extraLarge,
|
|
tonalElevation = 6.dp,
|
|
) {
|
|
Column {
|
|
// Title bar
|
|
TopAppBar(
|
|
title = {
|
|
Column {
|
|
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
|
|
Text(
|
|
state.currentPath,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
}
|
|
},
|
|
navigationIcon = {
|
|
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
|
|
Icon(Icons.Default.ArrowBack, null)
|
|
}
|
|
},
|
|
actions = {
|
|
// Select current folder
|
|
TextButton(onClick = { onSelect(state.currentPath) }) {
|
|
Text("Select here")
|
|
}
|
|
},
|
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
|
)
|
|
|
|
HorizontalDivider()
|
|
|
|
when {
|
|
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
CircularProgressIndicator()
|
|
}
|
|
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
|
|
Text(state.error!!, color = MaterialTheme.colorScheme.error)
|
|
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") }
|
|
}
|
|
}
|
|
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") }
|
|
}
|
|
}
|
|
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
items(state.entries, key = { it.path }) { entry ->
|
|
BrowserEntry(
|
|
file = entry,
|
|
onClick = {
|
|
if (entry.isDirectory) vm.navigateTo(entry.path)
|
|
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
|
|
},
|
|
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
|
|
)
|
|
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun BrowserEntry(
|
|
file: RemoteFile,
|
|
onClick: () -> Unit,
|
|
onSelectFolder: (() -> Unit)?,
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable(onClick = onClick)
|
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
Icon(
|
|
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
|
|
contentDescription = null,
|
|
modifier = Modifier.size(24.dp),
|
|
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
Spacer(Modifier.width(14.dp))
|
|
Column(modifier = Modifier.weight(1f)) {
|
|
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
if (!file.isDirectory) {
|
|
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
if (onSelectFolder != null) {
|
|
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) {
|
|
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
|
}
|
|
} else {
|
|
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun Long.formatBytes(): String = when {
|
|
this < 1024 -> "${this}B"
|
|
this < 1_048_576 -> "${"%.1f".format(this / 1024.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"
|
|
}
|