feat(send): build adaptive transfer creation flow

This commit is contained in:
2026-07-10 22:06:31 +02:00
parent 0bf4da0ee9
commit b4306d6694
18 changed files with 1009 additions and 245 deletions

View File

@@ -2,7 +2,11 @@ package com.vnidrop.app.core
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Point
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -18,7 +22,7 @@ actual fun rememberShareFilePicker(
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri)))
onFilePicked(context.pickedShareFile(uri))
}
}
return remember(launcher) {
@@ -62,12 +66,33 @@ actual fun rememberReceiveFolderPicker(
}
}
private fun Context.displayName(uri: Uri): String {
private fun Context.pickedShareFile(uri: Uri): PickedShareFile {
var displayName: String? = null
var sizeBytes: ULong? = null
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
return cursor.getString(nameIndex)
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex)
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
sizeBytes = cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
}
}
return uri.lastPathSegment ?: "transfer"
}
return PickedShareFile(
value = uri.toString(),
displayName = displayName ?: uri.lastPathSegment ?: "transfer",
sizeBytes = sizeBytes,
thumbnailBytes = runCatching {
val bitmap = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentResolver.loadThumbnail(uri, android.util.Size(192, 192), null)
} else {
DocumentsContract.getDocumentThumbnail(contentResolver, uri, Point(192, 192), null)
}) ?: error("The document provider did not return a thumbnail")
java.io.ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
output.toByteArray()
}
}.getOrNull(),
)
}

View File

@@ -3,10 +3,29 @@
<string name="nav_receive">Receive</string>
<string name="nav_settings">Settings</string>
<string name="send_title">Send</string>
<string name="send_subtitle">Send files to another device when you are ready.</string>
<string name="send_empty_title">No active transfers</string>
<string name="send_empty_body">Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device.</string>
<string name="button_create_new_transfer">Create new transfer</string>
<string name="send_subtitle">Files you have made available from this device.</string>
<string name="send_empty_title">Share your first file</string>
<string name="send_empty_body">Choose a file, decide who can receive it, then share it directly from this device.</string>
<string name="button_create_new_transfer">New transfer</string>
<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 file from this device. You can review its details before creating the transfer.</string>
<string name="send_review_title">Review transfer</string>
<string name="send_access_title">Who can receive it?</string>
<string name="send_access_approval">Ask before each download</string>
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
<string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
<string name="send_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string>
<string name="button_choose_file">Choose file</string>
<string name="button_change_file">Change file</string>
<string name="button_share_file">Share file</string>
<string name="button_sharing_file">Preparing transfer…</string>
<string name="button_copy_ticket">Copy transfer link</string>
<string name="send_new_transfer_description">Create a new transfer</string>
<string name="source_title">Source</string>
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
<string name="button_select_file">Select file</string>
@@ -71,6 +90,7 @@
<string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="button_open_settings">Open Settings</string>
<string name="snackbar_dismiss">Dismiss</string>
<string name="value_on">On</string>
<string name="value_off">Off</string>
<string name="approval_connection_request">Connection request</string>

View File

@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
data class PickedShareFile(
val value: String,
val displayName: String,
val sizeBytes: ULong? = null,
val thumbnailBytes: ByteArray? = null,
)
interface ShareFilePicker {

View File

@@ -0,0 +1,217 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.displayNameForStatus
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.decodeToImageBitmap
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_create_new_transfer
import vnidrop.shared.generated.resources.send_empty_body
import vnidrop.shared.generated.resources.send_empty_title
import vnidrop.shared.generated.resources.send_new_transfer_description
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.send_transfers_title
@Composable
internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
FloatingActionButton(
onClick = onClick,
modifier = modifier,
containerColor = LocalVniDropColors.current.brandButton,
contentColor = Color.White,
) {
Icon(SendIcons.Plus, contentDescription = stringResource(Res.string.send_new_transfer_description))
}
}
@Composable
internal fun TransferCatalog(
transfers: List<Transfer>,
transferThumbnails: Map<ULong, ByteArray>,
windowClass: WindowClass,
onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = if (windowClass == WindowClass.Phone && transfers.isNotEmpty()) 96.dp else 24.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { CatalogHeader(showAction = windowClass != WindowClass.Phone && transfers.isNotEmpty(), onOpenComposer) }
if (transfers.isEmpty()) {
item { SendEmptyState(onOpenComposer) }
} else {
item {
Text(
stringResource(Res.string.send_transfers_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
items(transfers, key = Transfer::localId) { transfer ->
TransferListItem(transfer, transferThumbnails[transfer.transferId]) { onTransferSelected(transfer.transferId) }
}
}
}
}
@Composable
private fun CatalogHeader(showAction: Boolean, onOpenComposer: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(Res.string.send_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(Res.string.send_subtitle),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
if (showAction) {
Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_create_new_transfer), onClick = onOpenComposer)
}
}
}
@Composable
private fun SendEmptyState(onOpenComposer: () -> Unit) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier.size(68.dp).clip(RoundedCornerShape(22.dp)).background(colors.brandLink.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(SendIcons.File, contentDescription = null, tint = colors.brandLink, modifier = Modifier.size(30.dp))
}
Text(
stringResource(Res.string.send_empty_title),
modifier = Modifier.padding(top = 22.dp),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
stringResource(Res.string.send_empty_body),
modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp),
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
PrimaryButton(
stringResource(Res.string.button_create_new_transfer),
onClick = onOpenComposer,
modifier = Modifier.padding(top = 22.dp),
)
}
}
@Composable
private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onClick: () -> Unit) {
val colors = LocalVniDropColors.current
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.size(44.dp).background(colors.backgroundSurface300, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
FileArtwork(thumbnailBytes, Modifier.fillMaxSize())
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"${formatBytes(transfer.totalSize)} · ${accessPolicyLabel(transfer.accessPolicy)}",
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
StatusPill(displayNameForStatus(transfer.status), tone = transfer.status.pillTone())
Spacer(Modifier.width(8.dp))
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
}
}
}
@Composable
internal fun FileArtwork(thumbnailBytes: ByteArray?, modifier: Modifier = Modifier) {
val bitmap = remember(thumbnailBytes) { thumbnailBytes?.let { runCatching { it.decodeToImageBitmap() }.getOrNull() } }
if (bitmap != null) {
androidx.compose.foundation.Image(
bitmap = bitmap,
contentDescription = null,
modifier = modifier.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
)
} else {
Box(modifier, contentAlignment = Alignment.Center) {
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.foregroundLight, modifier = Modifier.size(22.dp))
}
}
}
private fun TransferStatus.pillTone(): PillTone = when (this) {
TransferStatus.Sharing, TransferStatus.Done -> PillTone.Brand
TransferStatus.Importing, TransferStatus.Receiving -> PillTone.Warning
TransferStatus.Failed, TransferStatus.Cancelled -> PillTone.Destructive
TransferStatus.Stopped -> PillTone.Neutral
}

View File

@@ -0,0 +1,75 @@
package com.vnidrop.app.feature.send
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
internal object SendIcons {
val Plus = lineIcon("Plus") {
moveTo(12f, 5f)
lineTo(12f, 19f)
moveTo(5f, 12f)
lineTo(19f, 12f)
}
val File = lineIcon("File") {
moveTo(14f, 2f)
lineTo(6f, 2f)
lineTo(6f, 22f)
lineTo(18f, 22f)
lineTo(18f, 6f)
close()
moveTo(14f, 2f)
lineTo(14f, 6f)
lineTo(18f, 6f)
}
val Back = lineIcon("Back") {
moveTo(19f, 12f)
lineTo(5f, 12f)
moveTo(12f, 19f)
lineTo(5f, 12f)
lineTo(12f, 5f)
}
val ChevronRight = lineIcon("ChevronRight") {
moveTo(9f, 18f)
lineTo(15f, 12f)
lineTo(9f, 6f)
}
val Shield = lineIcon("Shield") {
moveTo(12f, 2f)
lineTo(20f, 6f)
lineTo(20f, 12f)
arcTo(9f, 9f, 0f, false, true, 12f, 22f)
arcTo(9f, 9f, 0f, false, true, 4f, 12f)
lineTo(4f, 6f)
close()
}
val Globe = lineIcon("Globe") {
moveTo(21f, 12f)
arcTo(9f, 9f, 0f, true, true, 3f, 12f)
arcTo(9f, 9f, 0f, true, true, 21f, 12f)
moveTo(3f, 12f)
lineTo(21f, 12f)
moveTo(12f, 3f)
arcTo(14f, 14f, 0f, false, true, 12f, 21f)
arcTo(14f, 14f, 0f, false, true, 12f, 3f)
}
}
private fun lineIcon(name: String, block: PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
pathFillType = PathFillType.NonZero,
pathBuilder = block,
)
}.build()

View File

@@ -13,7 +13,6 @@ import com.vnidrop.app.ui.state.WindowClass
fun SendRoute(
viewModel: SendViewModel,
windowClass: WindowClass,
onUseTicket: (String) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
@@ -25,7 +24,6 @@ fun SendRoute(
when (effect) {
SendEffect.OpenFilePicker -> picker.pickFile()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
is SendEffect.UseTicket -> onUseTicket(effect.ticket)
}
}
}
@@ -34,12 +32,16 @@ fun SendRoute(
coreState = coreState,
state = state,
windowClass = windowClass,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
onClearFile = viewModel::clearSelectedSource,
onTransferNameChanged = viewModel::setTransferName,
onSenderNameChanged = viewModel::setSenderName,
onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare,
onTransferSelected = viewModel::openTransfer,
onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket,
onUseTicket = viewModel::useTicket,
)
}

View File

@@ -1,125 +1,62 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Modifier
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.Share
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PillTone
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.components.StatusPill
import com.vnidrop.app.ui.screens.EmptyText
import com.vnidrop.app.ui.screens.ProgressSection
import com.vnidrop.app.ui.screens.ScreenHeader
import com.vnidrop.app.ui.screens.SendEmptyState
import com.vnidrop.app.ui.screens.TicketText
import com.vnidrop.app.ui.screens.shouldShowEmptyState
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.formatBytes
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_clear
import vnidrop.shared.generated.resources.button_copy
import vnidrop.shared.generated.resources.button_create_share
import vnidrop.shared.generated.resources.button_creating_share
import vnidrop.shared.generated.resources.button_select_file
import vnidrop.shared.generated.resources.button_use_locally
import vnidrop.shared.generated.resources.field_sender_name
import vnidrop.shared.generated.resources.field_transfer_name
import vnidrop.shared.generated.resources.metadata_name
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_source
import vnidrop.shared.generated.resources.metadata_transfer
import vnidrop.shared.generated.resources.send_source_empty
import vnidrop.shared.generated.resources.send_subtitle
import vnidrop.shared.generated.resources.send_title
import vnidrop.shared.generated.resources.share_details_title
import vnidrop.shared.generated.resources.source_title
import vnidrop.shared.generated.resources.transfer_details_title
@Composable
fun SendScreen(
coreState: CoreState,
state: SendState,
windowClass: WindowClass,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit,
onUseTicket: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
if (!state.shouldShowEmptyState(coreState)) {
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
}
if (state.shouldShowEmptyState(coreState)) {
SendEmptyState(windowClass = windowClass, onCreateNewTransfer = onSelectFile)
} else {
SourceCard(state, onSelectFile, onClearFile)
DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare)
coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) }
ProgressSection(coreState)
}
}
}
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
@Composable
private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) {
AppCard(title = stringResource(Res.string.source_title)) {
if (state.selectedSource.isBlank()) {
EmptyText(stringResource(Res.string.send_source_empty))
Box(Modifier.fillMaxSize()) {
if (selectedTransfer != null) {
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
} else {
MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') })
MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource)
}
}
}
@Composable
private fun DetailsCard(
coreState: CoreState,
state: SendState,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onCreateShare: () -> Unit,
) {
AppCard(title = stringResource(Res.string.transfer_details_title)) {
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
PrimaryButton(
text = if (state.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
onClick = onCreateShare,
enabled = state.canCreateShare(coreState.isInitialized),
TransferCatalog(
transfers = outgoingTransfers,
transferThumbnails = state.transferThumbnails,
windowClass = windowClass,
onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected,
)
}
}
}
@Composable
private fun ShareCard(share: Share, onCopyTicket: (String) -> Unit, onUseTicket: (String) -> Unit) {
AppCard(
title = stringResource(Res.string.share_details_title),
trailing = {
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
},
) {
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
TicketText(share.ticket)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseTicket(share.ticket) })
if (state.isComposerOpen) {
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
TransferComposer(
coreInitialized = coreState.isInitialized,
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onClearFile = onClearFile,
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
)
}
}
}

View File

@@ -5,8 +5,12 @@ import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.preferences.PreferencesRepository
import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -14,26 +18,26 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private const val DefaultTransferName = "VniDrop transfer"
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.send_transfer_created
data class SendState(
val selectedSource: String = "",
val selectedDisplayName: String = "",
val transferName: String = DefaultTransferName,
val isComposerOpen: Boolean = false,
val selectedFile: PickedShareFile? = null,
val transferName: String = "",
val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
) {
val hasSelectedSource: Boolean
get() = selectedSource.isNotBlank()
fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && hasSelectedSource && !isSharing
fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
}
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
data class UseTicket(val ticket: String) : SendEffect
}
class SendViewModel(
@@ -48,7 +52,6 @@ class SendViewModel(
private val effects = Channel<SendEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow()
private var selectedFile: PickedShareFile? = null
init {
viewModelScope.launch {
@@ -60,15 +63,38 @@ class SendViewModel(
}
}
fun openComposer() {
if (_state.value.isSharing) return
_state.update {
it.copy(
isComposerOpen = true,
selectedFile = null,
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
)
}
}
fun dismissComposer() {
if (_state.value.isSharing) return
_state.update {
it.copy(
isComposerOpen = false,
selectedFile = null,
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
)
}
}
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun onFilePicked(file: PickedShareFile) {
selectedFile = file
_state.update {
it.copy(
selectedSource = file.value,
selectedDisplayName = file.displayName,
transferName = if (it.transferName.isBlank() || it.transferName == DefaultTransferName) file.displayName else it.transferName,
isComposerOpen = true,
selectedFile = file,
transferName = file.displayName,
)
}
}
@@ -76,33 +102,54 @@ class SendViewModel(
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
fun clearSelectedSource() {
selectedFile = null
_state.update { it.copy(selectedSource = "", selectedDisplayName = "") }
_state.update { it.copy(selectedFile = null, transferName = "") }
}
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
fun useTicket(ticket: String) = sendEffect(SendEffect.UseTicket(ticket))
fun createShare() {
val current = state.value
val file = current.selectedFile ?: return
if (!current.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isSharing = true) }
try {
val result = selectedFile?.let { file ->
fileSystemService.sharePickedFile(repository, file, current.transferName, current.senderName)
} ?: repository.sharePath(current.selectedSource, current.transferName, current.senderName)
result.onFailure(messages::error)
} finally {
_state.update { it.copy(isSharing = false) }
val result = fileSystemService.sharePickedFile(
repository = repository,
file = file,
transferName = current.transferName.trim(),
senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy,
)
result.fold(
onSuccess = { share ->
_state.update {
it.copy(
isComposerOpen = false,
selectedFile = null,
transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false,
transferThumbnails = file.thumbnailBytes?.let { bytes ->
it.transferThumbnails + (share.transferId to bytes)
} ?: it.transferThumbnails,
)
}
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isSharing = false) }
messages.error(error)
},
)
}
}
private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) }
}
}

View File

@@ -0,0 +1,220 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
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.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.QuietButton
import com.vnidrop.app.ui.state.WindowClass
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_clear
import vnidrop.shared.generated.resources.button_share_file
import vnidrop.shared.generated.resources.button_sharing_file
import vnidrop.shared.generated.resources.field_sender_name
import vnidrop.shared.generated.resources.field_transfer_name
import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_access_anyone_description
import vnidrop.shared.generated.resources.send_access_approval
import vnidrop.shared.generated.resources.send_access_approval_description
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_new_transfer_title
import vnidrop.shared.generated.resources.send_review_title
@Composable
internal fun TransferComposer(
coreInitialized: Boolean,
state: SendState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(Res.string.send_new_transfer_title), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
val file = state.selectedFile
if (file == null) {
ChooseFileStep(onSelectFile)
} else {
ReviewFileStep(
file = file,
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onClearFile = onClearFile,
onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged,
onCreateShare = onCreateShare,
coreInitialized = coreInitialized,
)
}
}
}
@Composable
private fun ChooseFileStep(onSelectFile: () -> Unit) {
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(
stringResource(Res.string.send_choose_file_body),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
Surface(shape = RoundedCornerShape(16.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
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)
}
}
}
@Composable
private fun ReviewFileStep(
file: PickedShareFile,
state: SendState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit,
coreInitialized: Boolean,
) {
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
SelectedFileCard(file)
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)
PolicyOption(
icon = SendIcons.Shield,
title = stringResource(Res.string.send_access_approval),
description = stringResource(Res.string.send_access_approval_description),
selected = state.accessPolicy == ShareAccessPolicy.RequireApproval,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.RequireApproval) },
)
PolicyOption(
icon = SendIcons.Globe,
title = stringResource(Res.string.send_access_anyone),
description = stringResource(Res.string.send_access_anyone_description),
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
)
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)
}
} 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_clear), onClick = onClearFile, enabled = !state.isSharing)
}
}
}
@Composable
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
PrimaryButton(
if (state.isSharing) stringResource(Res.string.button_sharing_file) else stringResource(Res.string.button_share_file),
onClick = onCreateShare,
modifier = modifier,
enabled = state.canCreateShare(coreInitialized),
)
}
@Composable
private fun SelectedFileCard(file: PickedShareFile) {
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))) {
FileArtwork(file.thumbnailBytes, Modifier.fillMaxSize())
}
Spacer(Modifier.width(12.dp))
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),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun PolicyOption(
icon: ImageVector,
title: String,
description: String,
selected: Boolean,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(if (selected) colors.backgroundSelection else colors.backgroundSurface200)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = if (selected) colors.brandLink else colors.foregroundLight, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(description, color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
RadioButton(selected = selected, onClick = null)
}
}

View File

@@ -0,0 +1,94 @@
package com.vnidrop.app.feature.send
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.screens.TicketText
import com.vnidrop.app.ui.state.displayNameForStatus
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_back
import vnidrop.shared.generated.resources.button_copy_ticket
import vnidrop.shared.generated.resources.metadata_files
import vnidrop.shared.generated.resources.metadata_size
import vnidrop.shared.generated.resources.metadata_status
import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_access_approval
import vnidrop.shared.generated.resources.send_access_title
import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_transfer_details_title
@Composable
internal fun TransferDetails(transfer: Transfer, onBack: () -> Unit, onCopyTicket: (String) -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(SendIcons.Back, contentDescription = stringResource(Res.string.button_back))
}
Text(
stringResource(Res.string.send_transfer_details_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
}
item {
AppCard(title = transfer.transferName ?: stringResource(Res.string.send_new_transfer_title)) {
DetailValue(stringResource(Res.string.metadata_status), displayNameForStatus(transfer.status))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.metadata_files), transfer.fileCount.toString())
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
}
}
transfer.ticket?.let { ticket ->
item {
AppCard(title = stringResource(Res.string.button_copy_ticket)) {
TicketText(ticket)
PrimaryButton(stringResource(Res.string.button_copy_ticket), onClick = { onCopyTicket(ticket) })
}
}
}
}
}
@Composable
private fun DetailValue(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
internal fun accessPolicyLabel(policy: ShareAccessPolicy): String = when (policy) {
ShareAccessPolicy.RequireApproval -> stringResource(Res.string.send_access_approval)
ShareAccessPolicy.AnyoneWithTransfer -> stringResource(Res.string.send_access_anyone)
}

View File

@@ -0,0 +1,53 @@
package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveDrawer(
windowClass: WindowClass,
onDismissRequest: () -> Unit,
content: @Composable () -> Unit,
) {
if (windowClass == WindowClass.Phone) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = LocalVniDropColors.current.backgroundDialog,
) {
Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 12.dp)) {
content()
}
}
} else {
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.86f).widthIn(max = 560.dp),
shape = RoundedCornerShape(20.dp),
color = LocalVniDropColors.current.backgroundDialog,
shadowElevation = 12.dp,
) {
content()
}
}
}
}

View File

@@ -1,77 +0,0 @@
package com.vnidrop.app.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.navigation.VniDropIcons
import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.isActiveTransfer
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_create_new_transfer
import vnidrop.shared.generated.resources.send_empty_body
import vnidrop.shared.generated.resources.send_empty_title
@Composable
internal fun SendEmptyState(windowClass: WindowClass, onCreateNewTransfer: () -> Unit) {
val colors = LocalVniDropColors.current
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = if (windowClass == WindowClass.Phone) 540.dp else 420.dp)
.padding(horizontal = 24.dp, vertical = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = VniDropIcons.Send,
contentDescription = null,
modifier = Modifier.size(96.dp),
tint = colors.brandDefault,
)
Text(
text = stringResource(Res.string.send_empty_title),
modifier = Modifier.padding(top = 24.dp),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(Res.string.send_empty_body),
modifier = Modifier
.padding(top = 10.dp)
.widthIn(max = 520.dp),
color = colors.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
PrimaryButton(
text = stringResource(Res.string.button_create_new_transfer),
onClick = onCreateNewTransfer,
modifier = Modifier.padding(top = 24.dp),
)
}
}
internal fun SendState.shouldShowEmptyState(coreState: CoreState): Boolean =
!hasSelectedSource &&
!isSharing &&
coreState.lastShare == null &&
coreState.transfers.none { it.direction == "send" && it.isActiveTransfer() }

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app.ui.state
import com.vnidrop.app.core.CoreEventModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import kotlin.math.roundToInt
enum class WindowClass {
@@ -27,19 +28,19 @@ data class TransferProgress(
val progress: Float?,
)
fun displayNameForStatus(status: String): String =
when (status.lowercase()) {
"sharing" -> "Sharing"
"receiving" -> "Receiving"
"done" -> "Done"
"cancelled" -> "Cancelled"
"stopped" -> "Stopped"
"failed" -> "Failed"
else -> status.replaceFirstChar { it.uppercase() }
fun displayNameForStatus(status: TransferStatus): String =
when (status) {
TransferStatus.Importing -> "Preparing"
TransferStatus.Sharing -> "Available"
TransferStatus.Receiving -> "Receiving"
TransferStatus.Done -> "Completed"
TransferStatus.Cancelled -> "Cancelled"
TransferStatus.Stopped -> "Stopped"
TransferStatus.Failed -> "Failed"
}
fun Transfer.isActiveTransfer(): Boolean =
status.lowercase() in activeTransferStatuses
status in activeTransferStatuses
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
events
@@ -81,7 +82,7 @@ fun formatBytes(size: ULong): String {
}
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
private val activeTransferStatuses = setOf("importing", "sharing", "receiving")
private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
private fun eventLabel(event: CoreEventModel): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() }

View File

@@ -2,8 +2,11 @@ package com.vnidrop.app.feature
import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel
@@ -139,11 +142,49 @@ class ViewModelsTest {
fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController())
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg"))
viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
assertEquals("photo.jpg", viewModel.state.value.transferName)
assertTrue(viewModel.state.value.hasSelectedSource)
assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource()
assertFalse(viewModel.state.value.hasSelectedSource)
assertEquals(null, viewModel.state.value.selectedFile)
}
@Test
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare()
advanceUntilIdle()
assertFalse(viewModel.state.value.isComposerOpen)
assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId)
}
@Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
advanceUntilIdle()
viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
viewModel.createShare()
advanceUntilIdle()
assertTrue(viewModel.state.value.isComposerOpen)
assertEquals("photo.jpg", viewModel.state.value.selectedFile?.displayName)
assertFalse(viewModel.state.value.isSharing)
}
@Test

View File

@@ -9,7 +9,11 @@ import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TicketInspectionModel
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.notifications.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission
@@ -30,15 +34,53 @@ class FakeCoreGateway : CoreGateway {
val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>()
var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>()
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
var lastShareAccessPolicy: ShareAccessPolicy? = null
override suspend fun initialize(appDataDir: String): Result<Unit> {
mutableState.value = mutableState.value.copy(isInitialized = true)
return Result.success(Unit)
}
override fun shutdown() = Unit
override suspend fun sharePath(path: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> {
lastShareAccessPolicy = accessPolicy
shareResult.onSuccess { share ->
mutableState.value = mutableState.value.copy(
transfers = listOf(
Transfer(
localId = "local-${share.transferId}",
transferId = share.transferId,
direction = TransferDirection.Send,
status = TransferStatus.Sharing,
peerId = null,
transferName = share.transferName,
contentHash = share.contentHash,
fileCount = share.fileCount,
totalSize = share.totalSize,
ticket = share.ticket,
accessPolicy = accessPolicy,
createdAt = 1L,
updatedAt = 1L,
),
) + mutableState.value.transfers,
)
}
return shareResult
}
override suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException())
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit)
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
@@ -93,5 +135,6 @@ class FakeFileSystemService(
file: PickedShareFile,
transferName: String,
senderName: String,
) = repository.sharePath(file.value, transferName, senderName)
accessPolicy: ShareAccessPolicy,
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
}

View File

@@ -3,6 +3,10 @@ package com.vnidrop.app.ui.state
import com.vnidrop.app.feature.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.resolveDarkTheme
import kotlin.test.Test
@@ -41,7 +45,10 @@ class AppUiModelsTest {
@Test
fun sendStateExposesShareEligibility() {
val ready = SendState(selectedSource = "/tmp/payload.txt")
val ready = SendState(
selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL),
transferName = "payload.txt",
)
assertTrue(ready.canCreateShare(coreInitialized = true))
assertFalse(ready.canCreateShare(coreInitialized = false))
@@ -67,24 +74,28 @@ class AppUiModelsTest {
@Test
fun transferActivityOnlyIncludesRunningStatuses() {
assertTrue(storedTransfer(status = "importing").isActiveTransfer())
assertTrue(storedTransfer(status = "sharing").isActiveTransfer())
assertTrue(storedTransfer(status = "receiving").isActiveTransfer())
assertFalse(storedTransfer(status = "done").isActiveTransfer())
assertFalse(storedTransfer(status = "failed").isActiveTransfer())
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer())
assertTrue(storedTransfer(status = TransferStatus.Importing).isActiveTransfer())
assertTrue(storedTransfer(status = TransferStatus.Sharing).isActiveTransfer())
assertTrue(storedTransfer(status = TransferStatus.Receiving).isActiveTransfer())
assertFalse(storedTransfer(status = TransferStatus.Done).isActiveTransfer())
assertFalse(storedTransfer(status = TransferStatus.Failed).isActiveTransfer())
assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer())
}
private fun storedTransfer(status: String): Transfer =
private fun storedTransfer(status: TransferStatus): Transfer =
Transfer(
localId = "local-1",
transferId = 1UL,
peerId = null,
direction = "send",
direction = TransferDirection.Send,
status = status,
transferName = "Demo",
contentHash = "hash",
ticket = null,
fileCount = 1UL,
totalSize = 128UL,
accessPolicy = ShareAccessPolicy.RequireApproval,
createdAt = 1L,
updatedAt = 1L,
)
}

View File

@@ -3,10 +3,17 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readBytes
import platform.Foundation.NSURL
import platform.Foundation.NSFileManager
import platform.Foundation.NSFileSize
import platform.Foundation.NSNumber
import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIDocumentInteractionController
import platform.UIKit.UIImage
import platform.UIKit.UIImagePNGRepresentation
import platform.UIKit.UIModalPresentationFormSheet
import platform.UniformTypeIdentifiers.UTTypeFolder
import platform.UniformTypeIdentifiers.UTTypeItem
@@ -83,7 +90,21 @@ private class DocumentPickerDelegate(
onError("The selected iOS document URL was invalid")
} else {
val displayName = url.lastPathComponent ?: "transfer"
onFilePicked(PickedShareFile(url.absoluteString ?: url.path.orEmpty(), displayName))
val didStartAccess = url.startAccessingSecurityScopedResource()
val sizeBytes = try {
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
} finally {
if (didStartAccess) url.stopAccessingSecurityScopedResource()
}
onFilePicked(
PickedShareFile(
url.absoluteString ?: url.path.orEmpty(),
displayName,
sizeBytes,
nativeFileIcon(url),
),
)
}
retainedPickerDelegate = null
}
@@ -92,3 +113,11 @@ private class DocumentPickerDelegate(
retainedPickerDelegate = null
}
}
@OptIn(ExperimentalForeignApi::class)
private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching {
val controller = UIDocumentInteractionController.interactionControllerWithURL(url)
val icon = controller.icons.lastOrNull() as? UIImage ?: return null
val data = UIImagePNGRepresentation(icon) ?: return null
data.bytes?.readBytes(data.length.toInt())
}.getOrNull()

View File

@@ -6,8 +6,12 @@ import java.awt.EventQueue
import java.awt.FileDialog
import java.awt.Frame
import java.awt.KeyboardFocusManager
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileSystemView
@Composable
actual fun rememberShareFilePicker(
@@ -75,7 +79,12 @@ private fun pickShareFile(): PickedShareFile? {
val file = dialog.file
if (directory != null && file != null) {
val selected = File(directory, file)
PickedShareFile(selected.absolutePath, selected.name)
PickedShareFile(
selected.absolutePath,
selected.name,
selected.length().takeIf { it >= 0L }?.toULong(),
selected.systemIconPng(),
)
} else {
null
}
@@ -84,6 +93,21 @@ private fun pickShareFile(): PickedShareFile? {
}
}
private fun File.systemIconPng(): ByteArray? = runCatching {
val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128)
val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
try {
icon.paintIcon(null, graphics, 0, 0)
} finally {
graphics.dispose()
}
ByteArrayOutputStream().use { output ->
ImageIO.write(image, "png", output)
output.toByteArray()
}
}.getOrNull()
private fun pickDirectory(): File? =
if (isMacOs()) {
val dialog = withMacDirectoryDialog {