99193af2c5
Android 15+ enforces edge-to-edge on Dialog windows, making standard Compose WindowInsets APIs return 0 inside dialogs. Fix: use ViewCompat insets listener inside the Dialog to read actual system bar heights, with 56dp minimum to guarantee full nav bar clearance. Spacer inside the button Surface lets the elevated background extend behind the nav bar. Also make the entire local folder field tappable (not just the trailing icon) for better UX. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
374 lines
17 KiB
Kotlin
374 lines
17 KiB
Kotlin
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.platform.LocalDensity
|
|
import androidx.compose.ui.platform.LocalView
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import androidx.core.view.ViewCompat
|
|
import androidx.core.view.WindowInsetsCompat
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import com.syncflow.domain.model.RemoteFile
|
|
import kotlinx.coroutines.launch
|
|
|
|
@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()
|
|
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, decorFitsSystemWindows = false),
|
|
) {
|
|
val view = LocalView.current
|
|
val density = LocalDensity.current
|
|
var topInset by remember { mutableStateOf(0.dp) }
|
|
var bottomInset by remember { mutableStateOf(56.dp) }
|
|
DisposableEffect(view) {
|
|
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
|
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
with(density) {
|
|
topInset = bars.top.toDp()
|
|
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
|
|
}
|
|
insets
|
|
}
|
|
ViewCompat.requestApplyInsets(view)
|
|
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
|
|
}
|
|
|
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
|
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
|
|
|
|
// ── Top bar ──────────────────────────────────────────────────
|
|
TopAppBar(
|
|
navigationIcon = {
|
|
IconButton(onClick = { if (!vm.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 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 = {
|
|
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.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()
|
|
|
|
// ── 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)) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Select button ────────────────────────────────────────────
|
|
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
|
Column {
|
|
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,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(bottomInset))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 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),
|
|
) {
|
|
// 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.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.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))
|
|
}
|
|
}
|
|
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"
|
|
}
|