From addce8a80202b5fc263ec87fd02485d8377ec54e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:38:28 +0100 Subject: feat(app): add the Compose folder picker for moves A dialog with breadcrumbs, subfolder rows and New folder replaces the legacy folder picker activity for the list's move action; the editor will share it. New folder reuses the list's name dialog and checks. A second tap on Select no longer moves twice. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/list/FolderPicker.kt | 126 +++++++++++++++++++++ .../app/passwordstore/ui/list/ListDestination.kt | 56 +++++---- 2 files changed, 153 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt (limited to 'app/src/main/java') diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt new file mode 100644 index 00000000..5b610ff7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.FolderRow +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Picks a folder of the store at [root], starting at [start] (relative to the root, "" is the + * root): breadcrumbs jump up, rows open subfolders, "New folder" creates one inside the folder + * shown (the list's validation) and opens it, Select reports the folder shown, relative to the + * root. Used by the list's move action and by the editor. Shows no secrets; its window inherits + * FLAG_SECURE from the screen below. + */ +@Composable +fun FolderPicker( + root: File, + start: String, + onPick: (relativePath: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var path by rememberSaveable { mutableStateOf(start) } + var naming by rememberSaveable { mutableStateOf(false) } + val showHidden = remember { + context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) + } + val rows by + produceState>(persistentListOf(), path, naming) { + val dir = File(root, path) + // ponytail: a composable has no injected DispatcherProvider; plain IO for a directory read. + @Suppress("RawDispatchersUse") + value = withContext(Dispatchers.IO) { listRows(subfolders(dir, showHidden), dir).folders } + } + if (naming) { + // The list's own dialog and checks: blank, already a file or folder, outside the store + // (component-wise isInsideRepository). + val dir = File(root, path) + FolderNameDialog( + title = stringResource(R.string.title_create_folder), + message = null, + confirm = stringResource(R.string.button_create), + validate = { folderNameError(it, File(dir, it)) }, + onConfirm = { name -> + val folder = File(dir, name).canonicalFile + folder.mkdirs() + naming = false + path = folder.relativeTo(root.canonicalFile).path + }, + onDismiss = { naming = false }, + ) + } + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.folder_picker_title)) }, + text = { + Column { + BreadcrumbBar( + crumbsFor(path), + stringResource(R.string.list_store_root), + onCrumbClick = { path = it }, + modifier = Modifier.fillMaxWidth().padding(bottom = SpacingMedium), + ) + // ponytail: a plain scrolling column, fine for the folders of one level; a LazyColumn + // if a store ever has thousands of subfolders in one folder. + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + rows.forEach { row -> + key(row.path) { + FolderRow( + name = row.label, + count = row.count, + onClick = { path = File(row.path).relativeTo(root).path }, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onPick(path) }) { Text(stringResource(R.string.crypto_select)) } + }, + dismissButton = { + Row { + TextButton(onClick = { naming = true }) { Text(stringResource(R.string.folder_picker_new)) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt index 98c820b1..147e77b7 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -5,11 +5,7 @@ package app.passwordstore.ui.list -import android.app.Activity -import android.content.Intent import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -37,7 +33,6 @@ import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.compose.theme.SpacingMedium -import app.passwordstore.ui.folderselect.SelectFolderActivity import app.passwordstore.ui.git.base.BaseGitActivity.GitOp import app.passwordstore.ui.git.config.GitServerConfigActivity import app.passwordstore.ui.main.MainActivity @@ -55,14 +50,15 @@ private enum class ListDialog { NewFolder, Rename, Delete, + Move, Clone, NotInitialized, } /** * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the - * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root, - * [onOpenEntry] an entry file. + * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path + * relative to the root, [onOpenEntry] an entry file. */ @Composable fun ListDestination( @@ -106,20 +102,6 @@ fun ListDestination( BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() } BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" } - val moveLauncher = - rememberLauncherForActivityResult(StartActivityForResult()) { result -> - val data = result.data - val target = data?.getStringExtra("SELECTED_FOLDER_PATH")?.let(::File) - val files = data?.getStringArrayExtra("Files")?.map(::File) - if (result.resultCode == Activity.RESULT_OK && target?.isDirectory == true && files != null) { - selected = emptyList() - activity.lifecycleScope.launch { - activity.moveItems(files, target) - refresh() - } - } - } - fun ifInitialized(action: () -> Unit) { if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized } @@ -166,12 +148,7 @@ fun ListDestination( onGitOp = { op -> ifInitialized { runGit(op) } }, onSettings = activity::openSettings, onNew = { dialog = ListDialog.Create }, - onMove = { - moveLauncher.launch( - Intent(activity, SelectFolderActivity::class.java) - .putExtra("Files", selected.toTypedArray()) - ) - }, + onMove = { dialog = ListDialog.Move }, onDelete = { val files = selected.map(::File) val count = countFiles(files) @@ -254,6 +231,24 @@ fun ListDestination( onDismiss = { dialog = ListDialog.None }, dismiss = stringResource(R.string.dialog_no), ) + ListDialog.Move -> + FolderPicker( + root = root, + start = path, + onPick = { target -> + // One move per pick: a double tap on Select must not move twice. + if (dialog == ListDialog.Move) { + val files = selected.map(::File) + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.moveItems(files, File(root, target)) + refresh() + } + } + }, + onDismiss = { dialog = ListDialog.None }, + ) ListDialog.Clone -> Prompt( text = stringResource(R.string.clone_git_repo), @@ -276,9 +271,12 @@ fun ListDestination( } } -/** Asks for a folder name; [validate] returns an error string resource or null. */ +/** + * Asks for a folder name; [validate] returns an error string resource or null. Shared with the + * folder picker's "New folder". + */ @Composable -private fun FolderNameDialog( +internal fun FolderNameDialog( title: String, message: String?, confirm: String, -- cgit v1.2.3