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.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Point
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -18,7 +22,7 @@ actual fun rememberShareFilePicker(
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) { if (uri != null) {
onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri))) onFilePicked(context.pickedShareFile(uri))
} }
} }
return remember(launcher) { 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 -> contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) { val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
return cursor.getString(nameIndex) 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_receive">Receive</string>
<string name="nav_settings">Settings</string> <string name="nav_settings">Settings</string>
<string name="send_title">Send</string> <string name="send_title">Send</string>
<string name="send_subtitle">Send files to another device when you are ready.</string> <string name="send_subtitle">Files you have made available from this device.</string>
<string name="send_empty_title">No active transfers</string> <string name="send_empty_title">Share your first file</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="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">Create new transfer</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="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="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> <string name="button_select_file">Select file</string>
@@ -71,6 +90,7 @@
<string name="notifications_enabled_message">Notifications enabled.</string> <string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string> <string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="button_open_settings">Open 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_on">On</string>
<string name="value_off">Off</string> <string name="value_off">Off</string>
<string name="approval_connection_request">Connection request</string> <string name="approval_connection_request">Connection request</string>

View File

@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
data class PickedShareFile( data class PickedShareFile(
val value: String, val value: String,
val displayName: String, val displayName: String,
val sizeBytes: ULong? = null,
val thumbnailBytes: ByteArray? = null,
) )
interface ShareFilePicker { 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( fun SendRoute(
viewModel: SendViewModel, viewModel: SendViewModel,
windowClass: WindowClass, windowClass: WindowClass,
onUseTicket: (String) -> Unit,
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle()
@@ -25,7 +24,6 @@ fun SendRoute(
when (effect) { when (effect) {
SendEffect.OpenFilePicker -> picker.pickFile() SendEffect.OpenFilePicker -> picker.pickFile()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket)) is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
is SendEffect.UseTicket -> onUseTicket(effect.ticket)
} }
} }
} }
@@ -34,12 +32,16 @@ fun SendRoute(
coreState = coreState, coreState = coreState,
state = state, state = state,
windowClass = windowClass, windowClass = windowClass,
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile, onSelectFile = viewModel::selectFile,
onClearFile = viewModel::clearSelectedSource, onClearFile = viewModel::clearSelectedSource,
onTransferNameChanged = viewModel::setTransferName, onTransferNameChanged = viewModel::setTransferName,
onSenderNameChanged = viewModel::setSenderName, onSenderNameChanged = viewModel::setSenderName,
onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare, onCreateShare = viewModel::createShare,
onTransferSelected = viewModel::openTransfer,
onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket, onCopyTicket = viewModel::copyTicket,
onUseTicket = viewModel::useTicket,
) )
} }

View File

@@ -1,125 +1,62 @@
package com.vnidrop.app.feature.send package com.vnidrop.app.feature.send
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable 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.CoreState
import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.AdaptiveDrawer
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.ui.state.WindowClass 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 @Composable
fun SendScreen( fun SendScreen(
coreState: CoreState, coreState: CoreState,
state: SendState, state: SendState,
windowClass: WindowClass, windowClass: WindowClass,
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
onClearFile: () -> Unit, onClearFile: () -> Unit,
onTransferNameChanged: (String) -> Unit, onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit, onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit,
onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit, onCopyTicket: (String) -> Unit,
onUseTicket: (String) -> Unit,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
if (!state.shouldShowEmptyState(coreState)) { val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
} Box(Modifier.fillMaxSize()) {
if (state.shouldShowEmptyState(coreState)) { if (selectedTransfer != null) {
SendEmptyState(windowClass = windowClass, onCreateNewTransfer = onSelectFile) TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
} else { } else {
SourceCard(state, onSelectFile, onClearFile) TransferCatalog(
DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare) transfers = outgoingTransfers,
coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) } transferThumbnails = state.transferThumbnails,
ProgressSection(coreState) windowClass = windowClass,
onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected,
)
} }
} }
}
@Composable if (state.isComposerOpen) {
private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) { AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
AppCard(title = stringResource(Res.string.source_title)) { TransferComposer(
if (state.selectedSource.isBlank()) { coreInitialized = coreState.isInitialized,
EmptyText(stringResource(Res.string.send_source_empty)) state = state,
} else { windowClass = windowClass,
MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') }) onSelectFile = onSelectFile,
MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource) onClearFile = onClearFile,
} onTransferNameChanged = onTransferNameChanged,
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { onSenderNameChanged = onSenderNameChanged,
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile) onAccessPolicyChanged = onAccessPolicyChanged,
SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource) onCreateShare = onCreateShare,
} )
}
}
@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),
)
}
}
@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) })
} }
} }
} }

View File

@@ -5,8 +5,12 @@ import androidx.lifecycle.viewModelScope
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.preferences.PreferencesRepository 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.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -14,26 +18,26 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
private const val DefaultTransferName = "VniDrop transfer" import vnidrop.shared.generated.resources.send_transfer_created
data class SendState( data class SendState(
val selectedSource: String = "", val isComposerOpen: Boolean = false,
val selectedDisplayName: String = "", val selectedFile: PickedShareFile? = null,
val transferName: String = DefaultTransferName, val transferName: String = "",
val senderName: String = "", val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
val isSharing: Boolean = false, val isSharing: Boolean = false,
val selectedTransferId: ULong? = null,
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
) { ) {
val hasSelectedSource: Boolean fun canCreateShare(coreInitialized: Boolean): Boolean =
get() = selectedSource.isNotBlank() coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && hasSelectedSource && !isSharing
} }
sealed interface SendEffect { sealed interface SendEffect {
data object OpenFilePicker : SendEffect data object OpenFilePicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect data class CopyTicket(val ticket: String) : SendEffect
data class UseTicket(val ticket: String) : SendEffect
} }
class SendViewModel( class SendViewModel(
@@ -48,7 +52,6 @@ class SendViewModel(
private val effects = Channel<SendEffect>(Channel.BUFFERED) private val effects = Channel<SendEffect>(Channel.BUFFERED)
val effectFlow = effects.receiveAsFlow() val effectFlow = effects.receiveAsFlow()
private var selectedFile: PickedShareFile? = null
init { init {
viewModelScope.launch { 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 selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun onFilePicked(file: PickedShareFile) { fun onFilePicked(file: PickedShareFile) {
selectedFile = file
_state.update { _state.update {
it.copy( it.copy(
selectedSource = file.value, isComposerOpen = true,
selectedDisplayName = file.displayName, selectedFile = file,
transferName = if (it.transferName.isBlank() || it.transferName == DefaultTransferName) file.displayName else it.transferName, transferName = file.displayName,
) )
} }
} }
@@ -76,33 +102,54 @@ class SendViewModel(
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason)) fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
fun clearSelectedSource() { fun clearSelectedSource() {
selectedFile = null _state.update { it.copy(selectedFile = null, transferName = "") }
_state.update { it.copy(selectedSource = "", selectedDisplayName = "") }
} }
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
fun setSenderName(value: String) = _state.update { it.copy(senderName = 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 copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
fun useTicket(ticket: String) = sendEffect(SendEffect.UseTicket(ticket))
fun createShare() { fun createShare() {
val current = state.value val current = state.value
val file = current.selectedFile ?: return
if (!current.canCreateShare(coreState.value.isInitialized)) return if (!current.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isSharing = true) } _state.update { it.copy(isSharing = true) }
try { val result = fileSystemService.sharePickedFile(
val result = selectedFile?.let { file -> repository = repository,
fileSystemService.sharePickedFile(repository, file, current.transferName, current.senderName) file = file,
} ?: repository.sharePath(current.selectedSource, current.transferName, current.senderName) transferName = current.transferName.trim(),
result.onFailure(messages::error) senderName = current.senderName.trim(),
} finally { accessPolicy = current.accessPolicy,
_state.update { it.copy(isSharing = false) } )
} 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) { private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) } 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.CoreEventModel
import com.vnidrop.app.core.Transfer import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
enum class WindowClass { enum class WindowClass {
@@ -27,19 +28,19 @@ data class TransferProgress(
val progress: Float?, val progress: Float?,
) )
fun displayNameForStatus(status: String): String = fun displayNameForStatus(status: TransferStatus): String =
when (status.lowercase()) { when (status) {
"sharing" -> "Sharing" TransferStatus.Importing -> "Preparing"
"receiving" -> "Receiving" TransferStatus.Sharing -> "Available"
"done" -> "Done" TransferStatus.Receiving -> "Receiving"
"cancelled" -> "Cancelled" TransferStatus.Done -> "Completed"
"stopped" -> "Stopped" TransferStatus.Cancelled -> "Cancelled"
"failed" -> "Failed" TransferStatus.Stopped -> "Stopped"
else -> status.replaceFirstChar { it.uppercase() } TransferStatus.Failed -> "Failed"
} }
fun Transfer.isActiveTransfer(): Boolean = fun Transfer.isActiveTransfer(): Boolean =
status.lowercase() in activeTransferStatuses status in activeTransferStatuses
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> = fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
events events
@@ -81,7 +82,7 @@ fun formatBytes(size: ULong): String {
} }
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle") 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 { private fun eventLabel(event: CoreEventModel): String {
val direction = event.direction?.replaceFirstChar { it.uppercase() } 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.DeviceInfo
import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind 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.app.AppViewModel
import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.send.SendViewModel
@@ -139,11 +142,49 @@ class ViewModelsTest {
fun sendViewModelOwnsSelectedFileState() = runTest { fun sendViewModelOwnsSelectedFileState() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController()) 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) assertEquals("photo.jpg", viewModel.state.value.transferName)
assertTrue(viewModel.state.value.hasSelectedSource) assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource() 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 @Test

View File

@@ -9,7 +9,11 @@ import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TicketInspectionModel 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.LocalNotification
import com.vnidrop.app.notifications.LocalNotificationService import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.notifications.NotificationPermission
@@ -30,15 +34,53 @@ class FakeCoreGateway : CoreGateway {
val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>() val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>()
var responseResult: Result<Unit> = Result.success(Unit) var responseResult: Result<Unit> = Result.success(Unit)
val responses = mutableListOf<Triple<String, Boolean, String?>>() 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> { override suspend fun initialize(appDataDir: String): Result<Unit> {
mutableState.value = mutableState.value.copy(isInitialized = true) mutableState.value = mutableState.value.copy(isInitialized = true)
return Result.success(Unit) return Result.success(Unit)
} }
override fun shutdown() = Unit override fun shutdown() = Unit
override suspend fun sharePath(path: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException()) override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> {
override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException()) lastShareAccessPolicy = accessPolicy
override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException()) 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 inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException())
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit) 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) override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
@@ -93,5 +135,6 @@ class FakeFileSystemService(
file: PickedShareFile, file: PickedShareFile,
transferName: String, transferName: String,
senderName: 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.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.core.Transfer 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.ThemeMode
import com.vnidrop.app.ui.theme.resolveDarkTheme import com.vnidrop.app.ui.theme.resolveDarkTheme
import kotlin.test.Test import kotlin.test.Test
@@ -41,7 +45,10 @@ class AppUiModelsTest {
@Test @Test
fun sendStateExposesShareEligibility() { 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)) assertTrue(ready.canCreateShare(coreInitialized = true))
assertFalse(ready.canCreateShare(coreInitialized = false)) assertFalse(ready.canCreateShare(coreInitialized = false))
@@ -67,24 +74,28 @@ class AppUiModelsTest {
@Test @Test
fun transferActivityOnlyIncludesRunningStatuses() { fun transferActivityOnlyIncludesRunningStatuses() {
assertTrue(storedTransfer(status = "importing").isActiveTransfer()) assertTrue(storedTransfer(status = TransferStatus.Importing).isActiveTransfer())
assertTrue(storedTransfer(status = "sharing").isActiveTransfer()) assertTrue(storedTransfer(status = TransferStatus.Sharing).isActiveTransfer())
assertTrue(storedTransfer(status = "receiving").isActiveTransfer()) assertTrue(storedTransfer(status = TransferStatus.Receiving).isActiveTransfer())
assertFalse(storedTransfer(status = "done").isActiveTransfer()) assertFalse(storedTransfer(status = TransferStatus.Done).isActiveTransfer())
assertFalse(storedTransfer(status = "failed").isActiveTransfer()) assertFalse(storedTransfer(status = TransferStatus.Failed).isActiveTransfer())
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer()) assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer())
} }
private fun storedTransfer(status: String): Transfer = private fun storedTransfer(status: TransferStatus): Transfer =
Transfer( Transfer(
localId = "local-1", localId = "local-1",
transferId = 1UL, transferId = 1UL,
peerId = null, peerId = null,
direction = "send", direction = TransferDirection.Send,
status = status, status = status,
transferName = "Demo", transferName = "Demo",
contentHash = "hash",
ticket = null, ticket = null,
fileCount = 1UL, fileCount = 1UL,
totalSize = 128UL, 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readBytes
import platform.Foundation.NSURL import platform.Foundation.NSURL
import platform.Foundation.NSFileManager
import platform.Foundation.NSFileSize
import platform.Foundation.NSNumber
import platform.UIKit.UIApplication import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerDelegateProtocol import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIDocumentInteractionController
import platform.UIKit.UIImage
import platform.UIKit.UIImagePNGRepresentation
import platform.UIKit.UIModalPresentationFormSheet import platform.UIKit.UIModalPresentationFormSheet
import platform.UniformTypeIdentifiers.UTTypeFolder import platform.UniformTypeIdentifiers.UTTypeFolder
import platform.UniformTypeIdentifiers.UTTypeItem import platform.UniformTypeIdentifiers.UTTypeItem
@@ -83,7 +90,21 @@ private class DocumentPickerDelegate(
onError("The selected iOS document URL was invalid") onError("The selected iOS document URL was invalid")
} else { } else {
val displayName = url.lastPathComponent ?: "transfer" 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 retainedPickerDelegate = null
} }
@@ -92,3 +113,11 @@ private class DocumentPickerDelegate(
retainedPickerDelegate = null 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.FileDialog
import java.awt.Frame import java.awt.Frame
import java.awt.KeyboardFocusManager import java.awt.KeyboardFocusManager
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import javax.imageio.ImageIO
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileSystemView
@Composable @Composable
actual fun rememberShareFilePicker( actual fun rememberShareFilePicker(
@@ -75,7 +79,12 @@ private fun pickShareFile(): PickedShareFile? {
val file = dialog.file val file = dialog.file
if (directory != null && file != null) { if (directory != null && file != null) {
val selected = File(directory, file) val selected = File(directory, file)
PickedShareFile(selected.absolutePath, selected.name) PickedShareFile(
selected.absolutePath,
selected.name,
selected.length().takeIf { it >= 0L }?.toULong(),
selected.systemIconPng(),
)
} else { } else {
null 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? = private fun pickDirectory(): File? =
if (isMacOs()) { if (isMacOs()) {
val dialog = withMacDirectoryDialog { val dialog = withMacDirectoryDialog {