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.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp 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 android.content.Intent import android.net.Uri import android.os.Build import android.os.Environment import android.provider.Settings import androidx.compose.ui.platform.LocalContext 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>(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" } val context = LocalContext.current val hasAllFilesAccess = remember { Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager() } 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 (!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), ) // ── All-files-access banner ────────────────────────────────── if (!hasAllFilesAccess) { Surface(color = MaterialTheme.colorScheme.errorContainer) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(Icons.Default.Warning, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onErrorContainer) Text( "Grant \"All files access\" to browse and sync all folders", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.weight(1f), ) TextButton( onClick = { val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.fromParts("package", context.packageName, null)) else Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) context.startActivity(intent) }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), ) { Text("Grant", style = MaterialTheme.typography.labelSmall) } } } } // ── 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 { 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) } 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 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) }