mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(send): build adaptive transfer creation flow
This commit is contained in:
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,29 @@
|
||||
<string name="nav_receive">Receive</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="send_title">Send</string>
|
||||
<string name="send_subtitle">Send files to another device when you are ready.</string>
|
||||
<string name="send_empty_title">No active transfers</string>
|
||||
<string name="send_empty_body">Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device.</string>
|
||||
<string name="button_create_new_transfer">Create new transfer</string>
|
||||
<string name="send_subtitle">Files you have made available from this device.</string>
|
||||
<string name="send_empty_title">Share your first file</string>
|
||||
<string name="send_empty_body">Choose a file, decide who can receive it, then share it directly from this device.</string>
|
||||
<string name="button_create_new_transfer">New transfer</string>
|
||||
<string name="send_transfers_title">Your transfers</string>
|
||||
<string name="send_new_transfer_title">New transfer</string>
|
||||
<string name="send_choose_file_title">Choose what to share</string>
|
||||
<string name="send_choose_file_body">Select one file from this device. You can review its details before creating the transfer.</string>
|
||||
<string name="send_review_title">Review transfer</string>
|
||||
<string name="send_access_title">Who can receive it?</string>
|
||||
<string name="send_access_approval">Ask before each download</string>
|
||||
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
|
||||
<string name="send_access_anyone">Anyone with this transfer</string>
|
||||
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
|
||||
<string name="send_file_size_unknown">Size unavailable</string>
|
||||
<string name="send_transfer_created">Transfer created.</string>
|
||||
<string name="send_transfer_details_title">Transfer details</string>
|
||||
<string name="button_choose_file">Choose file</string>
|
||||
<string name="button_change_file">Change file</string>
|
||||
<string name="button_share_file">Share file</string>
|
||||
<string name="button_sharing_file">Preparing transfer…</string>
|
||||
<string name="button_copy_ticket">Copy transfer link</string>
|
||||
<string name="send_new_transfer_description">Create a new transfer</string>
|
||||
<string name="source_title">Source</string>
|
||||
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
|
||||
<string name="button_select_file">Select file</string>
|
||||
@@ -71,6 +90,7 @@
|
||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
||||
<string name="button_open_settings">Open Settings</string>
|
||||
<string name="snackbar_dismiss">Dismiss</string>
|
||||
<string name="value_on">On</string>
|
||||
<string name="value_off">Off</string>
|
||||
<string name="approval_connection_request">Connection request</string>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,125 +1,62 @@
|
||||
package com.vnidrop.app.feature.send
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vnidrop.app.core.CoreState
|
||||
import com.vnidrop.app.core.Share
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.Field
|
||||
import com.vnidrop.app.ui.components.MetadataRow
|
||||
import com.vnidrop.app.ui.components.PillTone
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
import com.vnidrop.app.ui.components.SecondaryButton
|
||||
import com.vnidrop.app.ui.components.StatusPill
|
||||
import com.vnidrop.app.ui.screens.EmptyText
|
||||
import com.vnidrop.app.ui.screens.ProgressSection
|
||||
import com.vnidrop.app.ui.screens.ScreenHeader
|
||||
import com.vnidrop.app.ui.screens.SendEmptyState
|
||||
import com.vnidrop.app.ui.screens.TicketText
|
||||
import com.vnidrop.app.ui.screens.shouldShowEmptyState
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
import com.vnidrop.app.ui.state.formatBytes
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_clear
|
||||
import vnidrop.shared.generated.resources.button_copy
|
||||
import vnidrop.shared.generated.resources.button_create_share
|
||||
import vnidrop.shared.generated.resources.button_creating_share
|
||||
import vnidrop.shared.generated.resources.button_select_file
|
||||
import vnidrop.shared.generated.resources.button_use_locally
|
||||
import vnidrop.shared.generated.resources.field_sender_name
|
||||
import vnidrop.shared.generated.resources.field_transfer_name
|
||||
import vnidrop.shared.generated.resources.metadata_name
|
||||
import vnidrop.shared.generated.resources.metadata_size
|
||||
import vnidrop.shared.generated.resources.metadata_source
|
||||
import vnidrop.shared.generated.resources.metadata_transfer
|
||||
import vnidrop.shared.generated.resources.send_source_empty
|
||||
import vnidrop.shared.generated.resources.send_subtitle
|
||||
import vnidrop.shared.generated.resources.send_title
|
||||
import vnidrop.shared.generated.resources.share_details_title
|
||||
import vnidrop.shared.generated.resources.source_title
|
||||
import vnidrop.shared.generated.resources.transfer_details_title
|
||||
|
||||
@Composable
|
||||
fun SendScreen(
|
||||
coreState: CoreState,
|
||||
state: SendState,
|
||||
windowClass: WindowClass,
|
||||
onOpenComposer: () -> Unit,
|
||||
onDismissComposer: () -> Unit,
|
||||
onSelectFile: () -> Unit,
|
||||
onClearFile: () -> Unit,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
onSenderNameChanged: (String) -> Unit,
|
||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
onTransferSelected: (ULong) -> Unit,
|
||||
onCloseTransferDetails: () -> Unit,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onUseTicket: (String) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
if (!state.shouldShowEmptyState(coreState)) {
|
||||
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
||||
}
|
||||
if (state.shouldShowEmptyState(coreState)) {
|
||||
SendEmptyState(windowClass = windowClass, onCreateNewTransfer = onSelectFile)
|
||||
} else {
|
||||
SourceCard(state, onSelectFile, onClearFile)
|
||||
DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare)
|
||||
coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) }
|
||||
ProgressSection(coreState)
|
||||
}
|
||||
}
|
||||
}
|
||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
|
||||
@Composable
|
||||
private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) {
|
||||
AppCard(title = stringResource(Res.string.source_title)) {
|
||||
if (state.selectedSource.isBlank()) {
|
||||
EmptyText(stringResource(Res.string.send_source_empty))
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (selectedTransfer != null) {
|
||||
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
|
||||
} else {
|
||||
MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') })
|
||||
MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
|
||||
SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailsCard(
|
||||
coreState: CoreState,
|
||||
state: SendState,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
onSenderNameChanged: (String) -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
) {
|
||||
AppCard(title = stringResource(Res.string.transfer_details_title)) {
|
||||
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
|
||||
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
|
||||
PrimaryButton(
|
||||
text = if (state.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
|
||||
onClick = onCreateShare,
|
||||
enabled = state.canCreateShare(coreState.isInitialized),
|
||||
TransferCatalog(
|
||||
transfers = outgoingTransfers,
|
||||
transferThumbnails = state.transferThumbnails,
|
||||
windowClass = windowClass,
|
||||
onOpenComposer = onOpenComposer,
|
||||
onTransferSelected = onTransferSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareCard(share: Share, onCopyTicket: (String) -> Unit, onUseTicket: (String) -> Unit) {
|
||||
AppCard(
|
||||
title = stringResource(Res.string.share_details_title),
|
||||
trailing = {
|
||||
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
||||
},
|
||||
) {
|
||||
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
||||
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
|
||||
TicketText(share.ticket)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
|
||||
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseTicket(share.ticket) })
|
||||
if (state.isComposerOpen) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
|
||||
TransferComposer(
|
||||
coreInitialized = coreState.isInitialized,
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
onSelectFile = onSelectFile,
|
||||
onClearFile = onClearFile,
|
||||
onTransferNameChanged = onTransferNameChanged,
|
||||
onSenderNameChanged = onSenderNameChanged,
|
||||
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||
onCreateShare = onCreateShare,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,12 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vnidrop.app.core.CoreGateway
|
||||
import com.vnidrop.app.core.FileSystemService
|
||||
import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.preferences.PreferencesRepository
|
||||
import com.vnidrop.app.ui.feedback.UiMessage
|
||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||
import com.vnidrop.app.ui.feedback.UiMessageTone
|
||||
import com.vnidrop.app.ui.feedback.UiText
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -14,26 +18,26 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DefaultTransferName = "VniDrop transfer"
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.send_transfer_created
|
||||
|
||||
data class SendState(
|
||||
val selectedSource: String = "",
|
||||
val selectedDisplayName: String = "",
|
||||
val transferName: String = DefaultTransferName,
|
||||
val isComposerOpen: Boolean = false,
|
||||
val selectedFile: PickedShareFile? = null,
|
||||
val transferName: String = "",
|
||||
val senderName: String = "",
|
||||
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
val isSharing: Boolean = false,
|
||||
val selectedTransferId: ULong? = null,
|
||||
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
||||
) {
|
||||
val hasSelectedSource: Boolean
|
||||
get() = selectedSource.isNotBlank()
|
||||
|
||||
fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && hasSelectedSource && !isSharing
|
||||
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
||||
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
||||
}
|
||||
|
||||
sealed interface SendEffect {
|
||||
data object OpenFilePicker : SendEffect
|
||||
data class CopyTicket(val ticket: String) : SendEffect
|
||||
data class UseTicket(val ticket: String) : SendEffect
|
||||
}
|
||||
|
||||
class SendViewModel(
|
||||
@@ -48,7 +52,6 @@ class SendViewModel(
|
||||
|
||||
private val effects = Channel<SendEffect>(Channel.BUFFERED)
|
||||
val effectFlow = effects.receiveAsFlow()
|
||||
private var selectedFile: PickedShareFile? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@@ -60,15 +63,38 @@ class SendViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun openComposer() {
|
||||
if (_state.value.isSharing) return
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = true,
|
||||
selectedFile = null,
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissComposer() {
|
||||
if (_state.value.isSharing) return
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
selectedFile = null,
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||
|
||||
fun onFilePicked(file: PickedShareFile) {
|
||||
selectedFile = file
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedSource = file.value,
|
||||
selectedDisplayName = file.displayName,
|
||||
transferName = if (it.transferName.isBlank() || it.transferName == DefaultTransferName) file.displayName else it.transferName,
|
||||
isComposerOpen = true,
|
||||
selectedFile = file,
|
||||
transferName = file.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -76,33 +102,54 @@ class SendViewModel(
|
||||
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
||||
|
||||
fun clearSelectedSource() {
|
||||
selectedFile = null
|
||||
_state.update { it.copy(selectedSource = "", selectedDisplayName = "") }
|
||||
_state.update { it.copy(selectedFile = null, transferName = "") }
|
||||
}
|
||||
|
||||
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
||||
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
|
||||
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
|
||||
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
|
||||
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
|
||||
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
|
||||
fun useTicket(ticket: String) = sendEffect(SendEffect.UseTicket(ticket))
|
||||
|
||||
fun createShare() {
|
||||
val current = state.value
|
||||
val file = current.selectedFile ?: return
|
||||
if (!current.canCreateShare(coreState.value.isInitialized)) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSharing = true) }
|
||||
try {
|
||||
val result = selectedFile?.let { file ->
|
||||
fileSystemService.sharePickedFile(repository, file, current.transferName, current.senderName)
|
||||
} ?: repository.sharePath(current.selectedSource, current.transferName, current.senderName)
|
||||
result.onFailure(messages::error)
|
||||
} finally {
|
||||
_state.update { it.copy(isSharing = false) }
|
||||
val result = fileSystemService.sharePickedFile(
|
||||
repository = repository,
|
||||
file = file,
|
||||
transferName = current.transferName.trim(),
|
||||
senderName = current.senderName.trim(),
|
||||
accessPolicy = current.accessPolicy,
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { share ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
selectedFile = null,
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
isSharing = false,
|
||||
transferThumbnails = file.thumbnailBytes?.let { bytes ->
|
||||
it.transferThumbnails + (share.transferId to bytes)
|
||||
} ?: it.transferThumbnails,
|
||||
)
|
||||
}
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.update { it.copy(isSharing = false) }
|
||||
messages.error(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendEffect(effect: SendEffect) {
|
||||
viewModelScope.launch { effects.send(effect) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -2,6 +2,7 @@ package com.vnidrop.app.ui.state
|
||||
|
||||
import com.vnidrop.app.core.CoreEventModel
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
enum class WindowClass {
|
||||
@@ -27,19 +28,19 @@ data class TransferProgress(
|
||||
val progress: Float?,
|
||||
)
|
||||
|
||||
fun displayNameForStatus(status: String): String =
|
||||
when (status.lowercase()) {
|
||||
"sharing" -> "Sharing"
|
||||
"receiving" -> "Receiving"
|
||||
"done" -> "Done"
|
||||
"cancelled" -> "Cancelled"
|
||||
"stopped" -> "Stopped"
|
||||
"failed" -> "Failed"
|
||||
else -> status.replaceFirstChar { it.uppercase() }
|
||||
fun displayNameForStatus(status: TransferStatus): String =
|
||||
when (status) {
|
||||
TransferStatus.Importing -> "Preparing"
|
||||
TransferStatus.Sharing -> "Available"
|
||||
TransferStatus.Receiving -> "Receiving"
|
||||
TransferStatus.Done -> "Completed"
|
||||
TransferStatus.Cancelled -> "Cancelled"
|
||||
TransferStatus.Stopped -> "Stopped"
|
||||
TransferStatus.Failed -> "Failed"
|
||||
}
|
||||
|
||||
fun Transfer.isActiveTransfer(): Boolean =
|
||||
status.lowercase() in activeTransferStatuses
|
||||
status in activeTransferStatuses
|
||||
|
||||
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||
events
|
||||
@@ -81,7 +82,7 @@ fun formatBytes(size: ULong): String {
|
||||
}
|
||||
|
||||
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
|
||||
private val activeTransferStatuses = setOf("importing", "sharing", "receiving")
|
||||
private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
|
||||
|
||||
private fun eventLabel(event: CoreEventModel): String {
|
||||
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,11 @@ import com.vnidrop.app.core.PickedShareFile
|
||||
import com.vnidrop.app.core.ReceiveFolder
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.Share
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.TicketInspectionModel
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.notifications.LocalNotification
|
||||
import com.vnidrop.app.notifications.LocalNotificationService
|
||||
import com.vnidrop.app.notifications.NotificationPermission
|
||||
@@ -30,15 +34,53 @@ class FakeCoreGateway : CoreGateway {
|
||||
val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>()
|
||||
var responseResult: Result<Unit> = Result.success(Unit)
|
||||
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
||||
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
|
||||
var lastShareAccessPolicy: ShareAccessPolicy? = null
|
||||
|
||||
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
override fun shutdown() = Unit
|
||||
override suspend fun sharePath(path: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> {
|
||||
lastShareAccessPolicy = accessPolicy
|
||||
shareResult.onSuccess { share ->
|
||||
mutableState.value = mutableState.value.copy(
|
||||
transfers = listOf(
|
||||
Transfer(
|
||||
localId = "local-${share.transferId}",
|
||||
transferId = share.transferId,
|
||||
direction = TransferDirection.Send,
|
||||
status = TransferStatus.Sharing,
|
||||
peerId = null,
|
||||
transferName = share.transferName,
|
||||
contentHash = share.contentHash,
|
||||
fileCount = share.fileCount,
|
||||
totalSize = share.totalSize,
|
||||
ticket = share.ticket,
|
||||
accessPolicy = accessPolicy,
|
||||
createdAt = 1L,
|
||||
updatedAt = 1L,
|
||||
),
|
||||
) + mutableState.value.transfers,
|
||||
)
|
||||
}
|
||||
return shareResult
|
||||
}
|
||||
override suspend fun shareFileDescriptor(
|
||||
fd: Int,
|
||||
displayName: String,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun shareSecurityScopedFileUrl(
|
||||
fileUrl: String,
|
||||
displayName: String,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = Result.failure<Share>(UnsupportedOperationException())
|
||||
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException())
|
||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit)
|
||||
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
||||
@@ -93,5 +135,6 @@ class FakeFileSystemService(
|
||||
file: PickedShareFile,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
) = repository.sharePath(file.value, transferName, senderName)
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user