mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(send): multi-file shares and receivers polish
Allow selecting multiple files on Android, desktop, and iOS and share them as one transfer through the core multi-source path. Update the composer UI for multi-file review, hide unfinished iOS receive methods, and surface refusal reasons on the receivers list.
This commit is contained in:
@@ -142,6 +142,13 @@ interface CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share>
|
||||
/** Multi-source share used by multi-file pickers. */
|
||||
suspend fun shareSources(
|
||||
sources: List<uniffi.vnidrop.ShareSource>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share>
|
||||
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
|
||||
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
|
||||
@@ -197,12 +197,13 @@ class CoreRepository(
|
||||
|
||||
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
|
||||
|
||||
private suspend fun shareSources(
|
||||
override suspend fun shareSources(
|
||||
sources: List<ShareSource>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = runCore {
|
||||
require(sources.isNotEmpty()) { "Select at least one file to share" }
|
||||
withPlatformPathAccess(sources) {
|
||||
requireCore().shareFiles(
|
||||
sources = sources,
|
||||
|
||||
@@ -10,7 +10,8 @@ data class PickedShareFile(
|
||||
)
|
||||
|
||||
interface ShareFilePicker {
|
||||
fun pickFile()
|
||||
/** Opens a platform picker that may return one or more files. */
|
||||
fun pickFiles()
|
||||
}
|
||||
|
||||
interface ReceiveFolderPicker {
|
||||
@@ -19,7 +20,7 @@ interface ReceiveFolderPicker {
|
||||
|
||||
@Composable
|
||||
expect fun rememberShareFilePicker(
|
||||
onFilePicked: (PickedShareFile) -> Unit,
|
||||
onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
): ShareFilePicker
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ interface FileSystemService {
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = sharePickedFiles(repository, listOf(file), transferName, senderName, accessPolicy)
|
||||
|
||||
suspend fun sharePickedFiles(
|
||||
repository: CoreGateway,
|
||||
files: List<PickedShareFile>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share>
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ fun SendRoute(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed)
|
||||
val picker = rememberShareFilePicker(viewModel::onFilesPicked, viewModel::onFilePickFailed)
|
||||
val shareActions = rememberTransferShareActions()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.effectFlow.collect { effect ->
|
||||
when (effect) {
|
||||
SendEffect.OpenFilePicker -> picker.pickFile()
|
||||
SendEffect.OpenFilePicker -> picker.pickFiles()
|
||||
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fun SendRoute(
|
||||
onDismissComposer = viewModel::dismissComposer,
|
||||
onSelectFile = viewModel::selectFile,
|
||||
onClearFile = viewModel::clearSelectedSource,
|
||||
onRemoveFile = viewModel::removeSelectedFile,
|
||||
onTransferNameChanged = viewModel::setTransferName,
|
||||
onSenderNameChanged = viewModel::setSenderName,
|
||||
onAccessPolicyChanged = viewModel::setAccessPolicy,
|
||||
|
||||
@@ -25,6 +25,7 @@ fun SendScreen(
|
||||
onDismissComposer: () -> Unit,
|
||||
onSelectFile: () -> Unit,
|
||||
onClearFile: () -> Unit,
|
||||
onRemoveFile: (String) -> Unit = {},
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
onSenderNameChanged: (String) -> Unit,
|
||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||
@@ -83,6 +84,7 @@ fun SendScreen(
|
||||
windowClass = windowClass,
|
||||
onSelectFile = onSelectFile,
|
||||
onClearFile = onClearFile,
|
||||
onRemoveFile = onRemoveFile,
|
||||
onTransferNameChanged = onTransferNameChanged,
|
||||
onSenderNameChanged = onSenderNameChanged,
|
||||
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||
|
||||
@@ -29,7 +29,7 @@ import vnidrop.shared.generated.resources.transfer_nfc_written
|
||||
|
||||
data class SendState(
|
||||
val isComposerOpen: Boolean = false,
|
||||
val selectedFile: PickedShareFile? = null,
|
||||
val selectedFiles: List<PickedShareFile> = emptyList(),
|
||||
val transferName: String = "",
|
||||
val senderName: String = "",
|
||||
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
@@ -42,8 +42,12 @@ data class SendState(
|
||||
val isDeleteConfirmationOpen: Boolean = false,
|
||||
val isDeleting: Boolean = false,
|
||||
) {
|
||||
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
|
||||
val totalSelectedBytes: ULong
|
||||
get() = selectedFiles.fold(0UL) { acc, file -> acc + (file.sizeBytes ?: 0UL) }
|
||||
|
||||
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
||||
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
||||
coreInitialized && selectedFiles.isNotEmpty() && transferName.isNotBlank() && !isSharing
|
||||
}
|
||||
|
||||
enum class TransferDetailPanel { Activity, Receivers, Share }
|
||||
@@ -111,7 +115,7 @@ class SendViewModel(
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = true,
|
||||
selectedFile = null,
|
||||
selectedFiles = emptyList(),
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
)
|
||||
@@ -123,7 +127,7 @@ class SendViewModel(
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
selectedFile = null,
|
||||
selectedFiles = emptyList(),
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
)
|
||||
@@ -132,12 +136,13 @@ class SendViewModel(
|
||||
|
||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||
|
||||
fun onFilePicked(file: PickedShareFile) {
|
||||
fun onFilesPicked(files: List<PickedShareFile>) {
|
||||
if (files.isEmpty()) return
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = true,
|
||||
selectedFile = file,
|
||||
transferName = file.displayName,
|
||||
selectedFiles = files,
|
||||
transferName = defaultTransferName(files),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,7 +150,21 @@ class SendViewModel(
|
||||
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
||||
|
||||
fun clearSelectedSource() {
|
||||
_state.update { it.copy(selectedFile = null, transferName = "") }
|
||||
_state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
|
||||
}
|
||||
|
||||
fun removeSelectedFile(value: String) {
|
||||
_state.update { current ->
|
||||
val remaining = current.selectedFiles.filterNot { it.value == value }
|
||||
current.copy(
|
||||
selectedFiles = remaining,
|
||||
transferName = when {
|
||||
remaining.isEmpty() -> ""
|
||||
current.transferName == defaultTransferName(current.selectedFiles) -> defaultTransferName(remaining)
|
||||
else -> current.transferName
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
||||
@@ -218,24 +237,25 @@ class SendViewModel(
|
||||
|
||||
fun createShare() {
|
||||
val current = state.value
|
||||
val file = current.selectedFile ?: return
|
||||
if (current.selectedFiles.isEmpty()) return
|
||||
if (!current.canCreateShare(coreState.value.isInitialized)) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSharing = true) }
|
||||
val result = fileSystemService.sharePickedFile(
|
||||
val result = fileSystemService.sharePickedFiles(
|
||||
repository = repository,
|
||||
file = file,
|
||||
files = current.selectedFiles,
|
||||
transferName = current.transferName.trim(),
|
||||
senderName = current.senderName.trim(),
|
||||
accessPolicy = current.accessPolicy,
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { share ->
|
||||
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) }
|
||||
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
|
||||
?.let { filePreviewRepository.save(share.transferId, it) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
selectedFile = null,
|
||||
selectedFiles = emptyList(),
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
isSharing = false,
|
||||
@@ -251,6 +271,12 @@ class SendViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
|
||||
0 -> ""
|
||||
1 -> files.first().displayName
|
||||
else -> "${files.size} files"
|
||||
}
|
||||
|
||||
private fun sendEffect(effect: SendEffect) {
|
||||
viewModelScope.launch { effects.send(effect) }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -39,9 +40,10 @@ import com.vnidrop.app.ui.state.formatBytes
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_change_file
|
||||
import vnidrop.shared.generated.resources.button_choose_file
|
||||
import vnidrop.shared.generated.resources.button_change_files
|
||||
import vnidrop.shared.generated.resources.button_choose_files
|
||||
import vnidrop.shared.generated.resources.button_clear
|
||||
import vnidrop.shared.generated.resources.button_remove_file
|
||||
import vnidrop.shared.generated.resources.button_share_file
|
||||
import vnidrop.shared.generated.resources.button_sharing_file
|
||||
import vnidrop.shared.generated.resources.field_sender_name
|
||||
@@ -55,6 +57,7 @@ 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_review_title
|
||||
import vnidrop.shared.generated.resources.send_selected_files_count
|
||||
|
||||
@Composable
|
||||
internal fun TransferComposer(
|
||||
@@ -63,6 +66,7 @@ internal fun TransferComposer(
|
||||
windowClass: WindowClass,
|
||||
onSelectFile: () -> Unit,
|
||||
onClearFile: () -> Unit,
|
||||
onRemoveFile: (String) -> Unit,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
onSenderNameChanged: (String) -> Unit,
|
||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||
@@ -72,16 +76,15 @@ internal fun TransferComposer(
|
||||
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val file = state.selectedFile
|
||||
if (file == null) {
|
||||
if (state.selectedFiles.isEmpty()) {
|
||||
ChooseFileStep(onSelectFile)
|
||||
} else {
|
||||
ReviewFileStep(
|
||||
file = file,
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
onSelectFile = onSelectFile,
|
||||
onClearFile = onClearFile,
|
||||
onRemoveFile = onRemoveFile,
|
||||
onTransferNameChanged = onTransferNameChanged,
|
||||
onSenderNameChanged = onSenderNameChanged,
|
||||
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||
@@ -107,18 +110,18 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
|
||||
PrimaryButton(stringResource(Res.string.button_choose_file), onClick = onSelectFile)
|
||||
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReviewFileStep(
|
||||
file: PickedShareFile,
|
||||
state: SendState,
|
||||
windowClass: WindowClass,
|
||||
onSelectFile: () -> Unit,
|
||||
onClearFile: () -> Unit,
|
||||
onRemoveFile: (String) -> Unit,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
onSenderNameChanged: (String) -> Unit,
|
||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||
@@ -126,7 +129,20 @@ private fun ReviewFileStep(
|
||||
coreInitialized: Boolean,
|
||||
) {
|
||||
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
SelectedFileCard(file)
|
||||
if (state.selectedFiles.size > 1) {
|
||||
Text(
|
||||
stringResource(Res.string.send_selected_files_count, state.selectedFiles.size),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
state.selectedFiles.forEach { file ->
|
||||
SelectedFileCard(
|
||||
file = file,
|
||||
canRemove = state.selectedFiles.size > 1 && !state.isSharing,
|
||||
onRemove = { onRemoveFile(file.value) },
|
||||
)
|
||||
}
|
||||
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
|
||||
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
|
||||
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
@@ -147,12 +163,12 @@ private fun ReviewFileStep(
|
||||
if (windowClass == WindowClass.Phone) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare)
|
||||
QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
||||
}
|
||||
}
|
||||
@@ -169,7 +185,11 @@ private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShar
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedFileCard(file: PickedShareFile) {
|
||||
private fun SelectedFileCard(
|
||||
file: PickedShareFile,
|
||||
canRemove: Boolean,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) {
|
||||
@@ -184,6 +204,11 @@ private fun SelectedFileCard(file: PickedShareFile) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (canRemove) {
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(SendIcons.Delete, stringResource(Res.string.button_remove_file), tint = LocalVniDropColors.current.destructiveDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ private fun ReceiverRow(receiver: ReceiverRequestModel) {
|
||||
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||
receiver.reason?.takeIf { it.isNotBlank() }?.let { reason ->
|
||||
Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user