mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(send): share folders as multi-file collections
Add folder pickers on Android, iOS, and desktop. Desktop and iOS walk directory paths in Rust; Android expands SAF trees to per-file FDs with relative collection paths because directory FDs are unsupported.
This commit is contained in:
@@ -10,8 +10,9 @@
|
||||
<string name="send_transfers_title">Your transfers</string>
|
||||
<string name="send_new_transfer_title">New transfer</string>
|
||||
<string name="send_choose_file_title">Choose what to share</string>
|
||||
<string name="send_choose_file_body">Select one or more files from this device. You can review them before creating the transfer.</string>
|
||||
<string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
|
||||
<string name="send_selected_files_count">%1$d files selected</string>
|
||||
<string name="send_folder_label">Folder</string>
|
||||
<string name="button_remove_file">Remove file</string>
|
||||
<string name="button_choose_files">Choose files</string>
|
||||
<string name="button_change_files">Change files</string>
|
||||
|
||||
@@ -7,11 +7,20 @@ data class PickedShareFile(
|
||||
val displayName: String,
|
||||
val sizeBytes: ULong? = null,
|
||||
val thumbnailBytes: ByteArray? = null,
|
||||
/**
|
||||
* When true, [value] is a directory (filesystem path, iOS security-scoped
|
||||
* folder URL, or Android document tree URI). Platform share code expands or
|
||||
* walks it; Rust cannot treat an Android FD as a directory.
|
||||
*/
|
||||
val isDirectory: Boolean = false,
|
||||
)
|
||||
|
||||
interface ShareFilePicker {
|
||||
/** Opens a platform picker that may return one or more files. */
|
||||
fun pickFiles()
|
||||
|
||||
/** Opens a platform folder picker for sharing a directory as one transfer. */
|
||||
fun pickFolder()
|
||||
}
|
||||
|
||||
interface ReceiveFolderPicker {
|
||||
|
||||
@@ -24,6 +24,7 @@ fun SendRoute(
|
||||
viewModel.effectFlow.collect { effect ->
|
||||
when (effect) {
|
||||
SendEffect.OpenFilePicker -> picker.pickFiles()
|
||||
SendEffect.OpenFolderPicker -> picker.pickFolder()
|
||||
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
||||
}
|
||||
}
|
||||
@@ -37,6 +38,7 @@ fun SendRoute(
|
||||
onOpenComposer = viewModel::openComposer,
|
||||
onDismissComposer = viewModel::dismissComposer,
|
||||
onSelectFile = viewModel::selectFile,
|
||||
onSelectFolder = viewModel::selectFolder,
|
||||
onClearFile = viewModel::clearSelectedSource,
|
||||
onRemoveFile = viewModel::removeSelectedFile,
|
||||
onTransferNameChanged = viewModel::setTransferName,
|
||||
|
||||
@@ -24,6 +24,7 @@ fun SendScreen(
|
||||
onOpenComposer: () -> Unit,
|
||||
onDismissComposer: () -> Unit,
|
||||
onSelectFile: () -> Unit,
|
||||
onSelectFolder: () -> Unit = {},
|
||||
onClearFile: () -> Unit,
|
||||
onRemoveFile: (String) -> Unit = {},
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
@@ -83,6 +84,7 @@ fun SendScreen(
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
onSelectFile = onSelectFile,
|
||||
onSelectFolder = onSelectFolder,
|
||||
onClearFile = onClearFile,
|
||||
onRemoveFile = onRemoveFile,
|
||||
onTransferNameChanged = onTransferNameChanged,
|
||||
|
||||
@@ -54,6 +54,7 @@ enum class TransferDetailPanel { Activity, Receivers, Share }
|
||||
|
||||
sealed interface SendEffect {
|
||||
data object OpenFilePicker : SendEffect
|
||||
data object OpenFolderPicker : SendEffect
|
||||
data class CopyTicket(val ticket: String) : SendEffect
|
||||
}
|
||||
|
||||
@@ -135,6 +136,7 @@ class SendViewModel(
|
||||
}
|
||||
|
||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
|
||||
|
||||
fun onFilesPicked(files: List<PickedShareFile>) {
|
||||
if (files.isEmpty()) return
|
||||
@@ -271,9 +273,11 @@ class SendViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
|
||||
0 -> ""
|
||||
1 -> files.first().displayName
|
||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
|
||||
files.isEmpty() -> ""
|
||||
files.size == 1 && files.first().isDirectory -> files.first().displayName
|
||||
files.size == 1 -> files.first().displayName
|
||||
files.all { it.isDirectory } -> "${files.size} folders"
|
||||
else -> "${files.size} files"
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_change_files
|
||||
import vnidrop.shared.generated.resources.button_choose_files
|
||||
import vnidrop.shared.generated.resources.button_choose_folder
|
||||
import vnidrop.shared.generated.resources.button_clear
|
||||
import vnidrop.shared.generated.resources.button_remove_file
|
||||
import vnidrop.shared.generated.resources.button_share_file
|
||||
@@ -56,6 +57,7 @@ import vnidrop.shared.generated.resources.send_access_title
|
||||
import vnidrop.shared.generated.resources.send_choose_file_body
|
||||
import vnidrop.shared.generated.resources.send_choose_file_title
|
||||
import vnidrop.shared.generated.resources.send_file_size_unknown
|
||||
import vnidrop.shared.generated.resources.send_folder_label
|
||||
import vnidrop.shared.generated.resources.send_review_title
|
||||
import vnidrop.shared.generated.resources.send_selected_files_count
|
||||
|
||||
@@ -65,6 +67,7 @@ internal fun TransferComposer(
|
||||
state: SendState,
|
||||
windowClass: WindowClass,
|
||||
onSelectFile: () -> Unit,
|
||||
onSelectFolder: () -> Unit,
|
||||
onClearFile: () -> Unit,
|
||||
onRemoveFile: (String) -> Unit,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
@@ -77,12 +80,13 @@ internal fun TransferComposer(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (state.selectedFiles.isEmpty()) {
|
||||
ChooseFileStep(onSelectFile)
|
||||
ChooseFileStep(onSelectFile, onSelectFolder)
|
||||
} else {
|
||||
ReviewFileStep(
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
onSelectFile = onSelectFile,
|
||||
onSelectFolder = onSelectFolder,
|
||||
onClearFile = onClearFile,
|
||||
onRemoveFile = onRemoveFile,
|
||||
onTransferNameChanged = onTransferNameChanged,
|
||||
@@ -96,7 +100,7 @@ internal fun TransferComposer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
||||
private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit) {
|
||||
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
stringResource(Res.string.send_choose_file_body),
|
||||
@@ -111,6 +115,7 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
||||
) {
|
||||
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
|
||||
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +125,7 @@ private fun ReviewFileStep(
|
||||
state: SendState,
|
||||
windowClass: WindowClass,
|
||||
onSelectFile: () -> Unit,
|
||||
onSelectFolder: () -> Unit,
|
||||
onClearFile: () -> Unit,
|
||||
onRemoveFile: (String) -> Unit,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
@@ -164,11 +170,13 @@ private fun ReviewFileStep(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare)
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +207,11 @@ private fun SelectedFileCard(
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown),
|
||||
when {
|
||||
file.isDirectory -> stringResource(Res.string.send_folder_label)
|
||||
file.sizeBytes != null -> formatBytes(file.sizeBytes)
|
||||
else -> stringResource(Res.string.send_file_size_unknown)
|
||||
},
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user