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:
2026-05-25 21:46:25 +00:00
parent c869f84a9d
commit 3c008ec8df
10 changed files with 638 additions and 94 deletions
@@ -0,0 +1,329 @@
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.graphics.vector.ImageVector
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private data class LocalEntry(val file: File, val childCount: Int)
private val STORAGE_ROOT = File("/storage/emulated/0")
private data class Shortcut(val label: String, val icon: ImageVector, val path: String)
private val SHORTCUTS = listOf(
Shortcut("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"),
Shortcut("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"),
Shortcut("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"),
Shortcut("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"),
Shortcut("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"),
Shortcut("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocalBrowserDialog(
initialPath: String = STORAGE_ROOT.absolutePath,
onSelect: (path: String) -> Unit,
onDismiss: () -> Unit,
) {
var currentPath by remember { mutableStateOf(File(initialPath.ifBlank { STORAGE_ROOT.absolutePath }).let { if (it.isDirectory) it else STORAGE_ROOT }) }
var pathStack by remember { mutableStateOf(listOf(currentPath)) }
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 scope = rememberCoroutineScope()
fun loadDir(dir: File) {
isLoading = true
entries = emptyList()
scope.launch {
val result = withContext(Dispatchers.IO) {
dir.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.sortedBy { it.name.lowercase() }
?.map { f -> LocalEntry(f, f.listFiles()?.count { it.isDirectory } ?: 0) }
?: emptyList()
}
entries = result
isLoading = false
}
}
fun navigateTo(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
}
fun navigateToBreadcrumb(dir: File) {
val idx = pathStack.indexOfLast { it.absolutePath == dir.absolutePath }
pathStack = if (idx >= 0) pathStack.take(idx + 1) else listOf(dir)
currentPath = dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
LaunchedEffect(Unit) { loadDir(currentPath) }
// Build breadcrumb segments relative to storage root
val relParts = currentPath.absolutePath
.removePrefix(STORAGE_ROOT.absolutePath)
.trimStart('/')
.split('/')
.filter { it.isNotEmpty() }
// Auto-scroll breadcrumbs to end
LaunchedEffect(currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) }
}
val filtered = if (searchQuery.isBlank()) entries
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = currentPath.name.ifBlank { "Internal Storage" }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
navigationIcon = {
IconButton(onClick = { if (!navigateUp()) onDismiss() }) {
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 folders…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Local Folder", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
}
},
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 = "📱 Storage", isLast = relParts.isEmpty(),
onClick = { navigateToBreadcrumb(STORAGE_ROOT) })
}
itemsIndexed(relParts) { idx, part ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val partPath = STORAGE_ROOT.absolutePath + "/" + relParts.take(idx + 1).joinToString("/")
BreadcrumbChip(label = part, isLast = idx == relParts.lastIndex,
onClick = { navigateToBreadcrumb(File(partPath)) })
}
}
}
HorizontalDivider()
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
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)) {
items(SHORTCUTS.filter { File(it.path).isDirectory }) { sc ->
ShortcutChip(sc, onClick = { navigateTo(File(sc.path)) })
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
Text("All folders", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp))
}
}
if (filtered.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), 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()) "No subfolders" else "No results for \"$searchQuery\"",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
items(filtered, key = { it.file.absolutePath }) { entry ->
LocalFolderItem(entry = entry, onClick = { navigateTo(entry.file) })
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.navigationBarsPadding()) {
Button(
onClick = { onSelect(currentPath.absolutePath) },
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)
}
}
}
}
}
}
}
@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 ShortcutChip(sc: Shortcut, onClick: () -> Unit) {
Surface(
onClick = onClick,
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(sc.icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.height(4.dp))
Text(sc.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1)
}
}
}
@Composable
private fun LocalFolderItem(entry: LocalEntry, 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(Color(0xFF1B5E20).copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Folder, null, Modifier.size(26.dp), tint = Color(0xFF2E7D32))
}
Column(modifier = Modifier.weight(1f)) {
Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
if (entry.childCount > 0) {
Text("${entry.childCount} subfolder${if (entry.childCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
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)
}