diff --git a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt index d08312e..aaae6d6 100644 --- a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt +++ b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt @@ -1,9 +1,5 @@ package com.syncflow.ui.addpair -import android.content.Intent -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -15,13 +11,13 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.domain.model.* +import com.syncflow.ui.browser.LocalBrowserDialog import com.syncflow.ui.browser.RemoteBrowserDialog import java.time.DayOfWeek @@ -31,17 +27,18 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) { val s by vm.state.collectAsState() LaunchedEffect(s.done) { if (s.done) onDone() } - val context = LocalContext.current var showRemoteBrowser by remember { mutableStateOf(false) } + var showLocalBrowser by remember { mutableStateOf(false) } - val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> - uri?.let { - context.contentResolver.takePersistableUriPermission( - it, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, - ) - vm.update { copy(localPath = it.toString()) } - } + if (showLocalBrowser) { + LocalBrowserDialog( + initialPath = s.localPath.ifBlank { "" }, + onSelect = { path -> + vm.update { copy(localPath = path) } + showLocalBrowser = false + }, + onDismiss = { showLocalBrowser = false }, + ) } if (showRemoteBrowser && s.selectedAccountId != -1L) { @@ -113,7 +110,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) { label = { Text("Local folder") }, leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) }, trailingIcon = { - IconButton(onClick = { dirPicker.launch(null) }) { + IconButton(onClick = { showLocalBrowser = true }) { Icon(Icons.Default.FolderOpen, "Browse") } }, diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/LocalBrowserDialog.kt b/app/src/main/kotlin/com/syncflow/ui/browser/LocalBrowserDialog.kt new file mode 100644 index 0000000..1606db0 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/browser/LocalBrowserDialog.kt @@ -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>(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) +} diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt index ff7ff74..b25ce1f 100644 --- a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt +++ b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt @@ -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" } diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt index 3e7f6d5..81c2e45 100644 --- a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt @@ -57,6 +57,27 @@ class RemoteBrowserViewModel @Inject constructor( return true } + fun navigateToBreadcrumb(path: String) { + val stack = _state.value.pathStack + val idx = stack.lastIndexOf(path) + val newStack = if (idx >= 0) stack.take(idx + 1) else listOf(path) + loadJob?.cancel() + _state.update { it.copy(currentPath = path, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) } + loadJob = loadPath(_state.value.accountId, path) + } + + fun createFolder(name: String) { + val s = _state.value + val newPath = if (s.currentPath.trimEnd('/') == "") "/$name" else "${s.currentPath.trimEnd('/')}/$name" + viewModelScope.launch { + val account = accountRepository.getAccount(s.accountId) ?: return@launch + val provider = runCatching { providerFactory.create(account) }.getOrElse { return@launch } + provider.createDirectory(newPath) + .onSuccess { retry() } + .onFailure { e -> _state.update { it.copy(error = "Could not create folder: ${e.message}") } } + } + } + fun retry() { val s = _state.value if (s.accountId == -1L) return diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index e3bd2ce..279183b 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 08428d9..84642a9 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index faea7e7..87661a4 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 366c33d..4357ecd 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 29d3c76..daa5636 100644 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/version.properties b/version.properties index 807b61f..fcbf9f7 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.40 -VERSION_CODE=41 +VERSION_NAME=1.0.47 +VERSION_CODE=48