diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt
index e3f101d..f8116b9 100644
--- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt
+++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt
@@ -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(),
+ )
}
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 41f495b..9318337 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -3,10 +3,29 @@
Receive
Settings
Send
- Send files to another device when you are ready.
- No active transfers
- Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device.
- Create new transfer
+ Files you have made available from this device.
+ Share your first file
+ Choose a file, decide who can receive it, then share it directly from this device.
+ New transfer
+ Your transfers
+ New transfer
+ Choose what to share
+ Select one file from this device. You can review its details before creating the transfer.
+ Review transfer
+ Who can receive it?
+ Ask before each download
+ You approve or refuse every new receiver.
+ Anyone with this transfer
+ No approval is required. Only use this for files you are comfortable sharing.
+ Size unavailable
+ Transfer created.
+ Transfer details
+ Choose file
+ Change file
+ Share file
+ Preparing transfer…
+ Copy transfer link
+ Create a new transfer
Source
Select a file to start a share. The app keeps bytes in Rust and platform file handles.
Select file
@@ -71,6 +90,7 @@
Notifications enabled.
Could not open notification settings.
Open Settings
+ Dismiss
On
Off
Connection request
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt
index 81a17d3..4d0b710 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt
@@ -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 {
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt
new file mode 100644
index 0000000..72c191d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt
@@ -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,
+ transferThumbnails: Map,
+ 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
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt
new file mode 100644
index 0000000..ce88401
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendIcons.kt
@@ -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()
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
index 8d3537e..6fdb0d4 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt
@@ -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,
)
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
index 12f4334..d6bfdc5 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt
@@ -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)
+ val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
+ val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
+
+ Box(Modifier.fillMaxSize()) {
+ if (selectedTransfer != null) {
+ TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
} else {
- SourceCard(state, onSelectFile, onClearFile)
- DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare)
- coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) }
- ProgressSection(coreState)
+ TransferCatalog(
+ transfers = outgoingTransfers,
+ transferThumbnails = state.transferThumbnails,
+ windowClass = windowClass,
+ onOpenComposer = onOpenComposer,
+ onTransferSelected = onTransferSelected,
+ )
}
}
-}
-@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))
- } 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),
- )
- }
-}
-
-@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,
+ )
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
index ef6e5ad..065ac15 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt
@@ -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 = 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(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) }
}
-
}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt
new file mode 100644
index 0000000..8ad4281
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt
@@ -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)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
new file mode 100644
index 0000000..a2b6ca0
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt
@@ -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)
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt
new file mode 100644
index 0000000..a62f6f9
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/AdaptiveDrawer.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt
deleted file mode 100644
index f7c0d30..0000000
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/screens/SendEmptyState.kt
+++ /dev/null
@@ -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() }
diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
index a639fca..6f946c8 100644
--- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
+++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/state/AppUiModels.kt
@@ -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): List =
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() }
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
index 80ab3dc..cefa3cb 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt
@@ -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
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt
index 92926f6..f0d9bec 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt
@@ -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>()
var responseResult: Result = Result.success(Unit)
val responses = mutableListOf>()
+ var shareResult: Result = Result.failure(UnsupportedOperationException())
+ var lastShareAccessPolicy: ShareAccessPolicy? = null
override suspend fun initialize(appDataDir: String): Result {
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(UnsupportedOperationException())
- override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException())
- override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException())
+ override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result {
+ 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(UnsupportedOperationException())
+ override suspend fun shareSecurityScopedFileUrl(
+ fileUrl: String,
+ displayName: String,
+ transferName: String,
+ senderName: String,
+ accessPolicy: ShareAccessPolicy,
+ ) = Result.failure(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = Result.failure(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)
}
diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
index 0dfb119..3cefe44 100644
--- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
+++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt
@@ -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,
)
}
diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt
index ff72d58..52499cb 100644
--- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt
+++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt
@@ -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()
diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt
index e784821..c7b5198 100644
--- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt
+++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt
@@ -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 {