v1.0.47: built-in folder browsers, icon crop fix, nav bar button fix
- Replace SAF document picker with custom LocalBrowserDialog (File API, quick-access shortcuts, breadcrumb nav, search, folder-only listing) - Rewrite RemoteBrowserDialog as full-screen dialog with breadcrumbs, search, and new-folder creation; add navigateToBreadcrumb/createFolder to RemoteBrowserViewModel - Fix Select button cut off by navigation bar in both browsers: wrap button in Column(navigationBarsPadding()) so the button sits above the nav bar rather than behind it - Tighten icon foreground crop to remove excess black border Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,36 @@
|
||||
package com.syncflow.ui.browser
|
||||
|
||||
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.automirrored.filled.ArrowBack
|
||||
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.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.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.domain.model.RemoteFile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -30,78 +44,186 @@ fun RemoteBrowserDialog(
|
||||
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
|
||||
|
||||
val state by vm.state.collectAsState()
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var searchActive by remember { mutableStateOf(false) }
|
||||
var showNewFolderDialog by remember { mutableStateOf(false) }
|
||||
val breadcrumbState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-scroll breadcrumbs to end when path changes
|
||||
val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
|
||||
LaunchedEffect(state.currentPath) {
|
||||
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segments.size)) }
|
||||
searchQuery = ""
|
||||
searchActive = false
|
||||
}
|
||||
|
||||
val filtered = if (searchQuery.isBlank()) state.entries
|
||||
else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
|
||||
val currentFolderName = state.currentPath.trimEnd('/').substringAfterLast('/').ifBlank { "Root" }
|
||||
|
||||
if (showNewFolderDialog) {
|
||||
NewFolderDialog(
|
||||
onConfirm = { name ->
|
||||
vm.createFolder(name)
|
||||
showNewFolderDialog = false
|
||||
},
|
||||
onDismiss = { showNewFolderDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.85f),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column {
|
||||
// Title bar
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
||||
|
||||
// ── Top 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)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
if (searchActive) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth().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,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text("Choose Folder", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Select current folder
|
||||
TextButton(onClick = { onSelect(state.currentPath) }) {
|
||||
Text("Select here")
|
||||
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
|
||||
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
|
||||
}
|
||||
IconButton(onClick = { showNewFolderDialog = true }) {
|
||||
Icon(Icons.Default.CreateNewFolder, "New Folder")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────
|
||||
Surface(tonalElevation = 1.dp) {
|
||||
LazyRow(
|
||||
state = breadcrumbState,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item {
|
||||
BreadcrumbChip(
|
||||
label = "⌂",
|
||||
isLast = segments.isEmpty(),
|
||||
onClick = { vm.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(
|
||||
label = seg,
|
||||
isLast = idx == segments.lastIndex,
|
||||
onClick = { vm.navigateToBreadcrumb(segPath) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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") }
|
||||
// ── 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 = vm::retry) {
|
||||
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
|
||||
filtered.isEmpty() && searchQuery.isBlank() -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("This folder is empty", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("You can still select it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
|
||||
}
|
||||
|
||||
filtered.isEmpty() -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Default.SearchOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(filtered, key = { it.path }) { entry ->
|
||||
FolderItem(
|
||||
file = entry,
|
||||
onClick = {
|
||||
if (entry.isDirectory) vm.navigateTo(entry.path)
|
||||
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
// ── Select button ────────────────────────────────────────────
|
||||
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.navigationBarsPadding()) {
|
||||
Button(
|
||||
onClick = { onSelect(state.currentPath) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Select \"$currentFolderName\"",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,44 +233,119 @@ fun RemoteBrowserDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowserEntry(
|
||||
file: RemoteFile,
|
||||
onClick: () -> Unit,
|
||||
onSelectFolder: (() -> Unit)?,
|
||||
) {
|
||||
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 FolderItem(file: 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),
|
||||
) {
|
||||
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))
|
||||
// Colored icon badge
|
||||
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 Icons.Default.InsertDriveFile,
|
||||
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.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
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.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
file.sizeBytes.formatBytes(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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)
|
||||
|
||||
if (file.isDirectory) {
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewFolderDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.CreateNewFolder, null) },
|
||||
title = { Text("New Folder") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Folder name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { if (name.isNotBlank()) onConfirm(name.trim()) }),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
|
||||
Text("Create")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user