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" }