mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Point
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -18,7 +22,7 @@ actual fun rememberShareFilePicker(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
onFilePicked(PickedShareFile(uri.toString(), context.displayName(uri)))
|
onFilePicked(context.pickedShareFile(uri))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return remember(launcher) {
|
return remember(launcher) {
|
||||||
@@ -62,12 +66,33 @@ actual fun rememberReceiveFolderPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Context.displayName(uri: Uri): String {
|
private fun Context.pickedShareFile(uri: Uri): PickedShareFile {
|
||||||
|
var displayName: String? = null
|
||||||
|
var sizeBytes: ULong? = null
|
||||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
return cursor.getString(nameIndex)
|
if (cursor.moveToFirst()) {
|
||||||
|
if (nameIndex >= 0 && !cursor.isNull(nameIndex)) displayName = cursor.getString(nameIndex)
|
||||||
|
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
|
||||||
|
sizeBytes = cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return uri.lastPathSegment ?: "transfer"
|
return PickedShareFile(
|
||||||
|
value = uri.toString(),
|
||||||
|
displayName = displayName ?: uri.lastPathSegment ?: "transfer",
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
thumbnailBytes = runCatching {
|
||||||
|
val bitmap = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
contentResolver.loadThumbnail(uri, android.util.Size(192, 192), null)
|
||||||
|
} else {
|
||||||
|
DocumentsContract.getDocumentThumbnail(contentResolver, uri, Point(192, 192), null)
|
||||||
|
}) ?: error("The document provider did not return a thumbnail")
|
||||||
|
java.io.ByteArrayOutputStream().use { output ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}.getOrNull(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,29 @@
|
|||||||
<string name="nav_receive">Receive</string>
|
<string name="nav_receive">Receive</string>
|
||||||
<string name="nav_settings">Settings</string>
|
<string name="nav_settings">Settings</string>
|
||||||
<string name="send_title">Send</string>
|
<string name="send_title">Send</string>
|
||||||
<string name="send_subtitle">Send files to another device when you are ready.</string>
|
<string name="send_subtitle">Files you have made available from this device.</string>
|
||||||
<string name="send_empty_title">No active transfers</string>
|
<string name="send_empty_title">Share your first file</string>
|
||||||
<string name="send_empty_body">Create a transfer when you are ready. You will pick what to send, review the details, and share it with another device.</string>
|
<string name="send_empty_body">Choose a file, decide who can receive it, then share it directly from this device.</string>
|
||||||
<string name="button_create_new_transfer">Create new transfer</string>
|
<string name="button_create_new_transfer">New transfer</string>
|
||||||
|
<string name="send_transfers_title">Your transfers</string>
|
||||||
|
<string name="send_new_transfer_title">New transfer</string>
|
||||||
|
<string name="send_choose_file_title">Choose what to share</string>
|
||||||
|
<string name="send_choose_file_body">Select one file from this device. You can review its details before creating the transfer.</string>
|
||||||
|
<string name="send_review_title">Review transfer</string>
|
||||||
|
<string name="send_access_title">Who can receive it?</string>
|
||||||
|
<string name="send_access_approval">Ask before each download</string>
|
||||||
|
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
|
||||||
|
<string name="send_access_anyone">Anyone with this transfer</string>
|
||||||
|
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
|
||||||
|
<string name="send_file_size_unknown">Size unavailable</string>
|
||||||
|
<string name="send_transfer_created">Transfer created.</string>
|
||||||
|
<string name="send_transfer_details_title">Transfer details</string>
|
||||||
|
<string name="button_choose_file">Choose file</string>
|
||||||
|
<string name="button_change_file">Change file</string>
|
||||||
|
<string name="button_share_file">Share file</string>
|
||||||
|
<string name="button_sharing_file">Preparing transfer…</string>
|
||||||
|
<string name="button_copy_ticket">Copy transfer link</string>
|
||||||
|
<string name="send_new_transfer_description">Create a new transfer</string>
|
||||||
<string name="source_title">Source</string>
|
<string name="source_title">Source</string>
|
||||||
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
|
<string name="send_source_empty">Select a file to start a share. The app keeps bytes in Rust and platform file handles.</string>
|
||||||
<string name="button_select_file">Select file</string>
|
<string name="button_select_file">Select file</string>
|
||||||
@@ -71,6 +90,7 @@
|
|||||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||||
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
||||||
<string name="button_open_settings">Open Settings</string>
|
<string name="button_open_settings">Open Settings</string>
|
||||||
|
<string name="snackbar_dismiss">Dismiss</string>
|
||||||
<string name="value_on">On</string>
|
<string name="value_on">On</string>
|
||||||
<string name="value_off">Off</string>
|
<string name="value_off">Off</string>
|
||||||
<string name="approval_connection_request">Connection request</string>
|
<string name="approval_connection_request">Connection request</string>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
|
|||||||
data class PickedShareFile(
|
data class PickedShareFile(
|
||||||
val value: String,
|
val value: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
|
val sizeBytes: ULong? = null,
|
||||||
|
val thumbnailBytes: ByteArray? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface ShareFilePicker {
|
interface ShareFilePicker {
|
||||||
|
|||||||
@@ -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(
|
fun SendRoute(
|
||||||
viewModel: SendViewModel,
|
viewModel: SendViewModel,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onUseTicket: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
|
||||||
@@ -25,7 +24,6 @@ fun SendRoute(
|
|||||||
when (effect) {
|
when (effect) {
|
||||||
SendEffect.OpenFilePicker -> picker.pickFile()
|
SendEffect.OpenFilePicker -> picker.pickFile()
|
||||||
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
|
||||||
is SendEffect.UseTicket -> onUseTicket(effect.ticket)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,12 +32,16 @@ fun SendRoute(
|
|||||||
coreState = coreState,
|
coreState = coreState,
|
||||||
state = state,
|
state = state,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
|
onOpenComposer = viewModel::openComposer,
|
||||||
|
onDismissComposer = viewModel::dismissComposer,
|
||||||
onSelectFile = viewModel::selectFile,
|
onSelectFile = viewModel::selectFile,
|
||||||
onClearFile = viewModel::clearSelectedSource,
|
onClearFile = viewModel::clearSelectedSource,
|
||||||
onTransferNameChanged = viewModel::setTransferName,
|
onTransferNameChanged = viewModel::setTransferName,
|
||||||
onSenderNameChanged = viewModel::setSenderName,
|
onSenderNameChanged = viewModel::setSenderName,
|
||||||
|
onAccessPolicyChanged = viewModel::setAccessPolicy,
|
||||||
onCreateShare = viewModel::createShare,
|
onCreateShare = viewModel::createShare,
|
||||||
|
onTransferSelected = viewModel::openTransfer,
|
||||||
|
onCloseTransferDetails = viewModel::closeTransferDetails,
|
||||||
onCopyTicket = viewModel::copyTicket,
|
onCopyTicket = viewModel::copyTicket,
|
||||||
onUseTicket = viewModel::useTicket,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,125 +1,62 @@
|
|||||||
package com.vnidrop.app.feature.send
|
package com.vnidrop.app.feature.send
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.Modifier
|
||||||
import com.vnidrop.app.core.CoreState
|
import com.vnidrop.app.core.CoreState
|
||||||
import com.vnidrop.app.core.Share
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.ui.components.AppCard
|
import com.vnidrop.app.core.TransferDirection
|
||||||
import com.vnidrop.app.ui.components.Field
|
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||||
import com.vnidrop.app.ui.components.MetadataRow
|
|
||||||
import com.vnidrop.app.ui.components.PillTone
|
|
||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
|
||||||
import com.vnidrop.app.ui.components.SecondaryButton
|
|
||||||
import com.vnidrop.app.ui.components.StatusPill
|
|
||||||
import com.vnidrop.app.ui.screens.EmptyText
|
|
||||||
import com.vnidrop.app.ui.screens.ProgressSection
|
|
||||||
import com.vnidrop.app.ui.screens.ScreenHeader
|
|
||||||
import com.vnidrop.app.ui.screens.SendEmptyState
|
|
||||||
import com.vnidrop.app.ui.screens.TicketText
|
|
||||||
import com.vnidrop.app.ui.screens.shouldShowEmptyState
|
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import vnidrop.shared.generated.resources.Res
|
|
||||||
import vnidrop.shared.generated.resources.button_clear
|
|
||||||
import vnidrop.shared.generated.resources.button_copy
|
|
||||||
import vnidrop.shared.generated.resources.button_create_share
|
|
||||||
import vnidrop.shared.generated.resources.button_creating_share
|
|
||||||
import vnidrop.shared.generated.resources.button_select_file
|
|
||||||
import vnidrop.shared.generated.resources.button_use_locally
|
|
||||||
import vnidrop.shared.generated.resources.field_sender_name
|
|
||||||
import vnidrop.shared.generated.resources.field_transfer_name
|
|
||||||
import vnidrop.shared.generated.resources.metadata_name
|
|
||||||
import vnidrop.shared.generated.resources.metadata_size
|
|
||||||
import vnidrop.shared.generated.resources.metadata_source
|
|
||||||
import vnidrop.shared.generated.resources.metadata_transfer
|
|
||||||
import vnidrop.shared.generated.resources.send_source_empty
|
|
||||||
import vnidrop.shared.generated.resources.send_subtitle
|
|
||||||
import vnidrop.shared.generated.resources.send_title
|
|
||||||
import vnidrop.shared.generated.resources.share_details_title
|
|
||||||
import vnidrop.shared.generated.resources.source_title
|
|
||||||
import vnidrop.shared.generated.resources.transfer_details_title
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendScreen(
|
fun SendScreen(
|
||||||
coreState: CoreState,
|
coreState: CoreState,
|
||||||
state: SendState,
|
state: SendState,
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
|
onOpenComposer: () -> Unit,
|
||||||
|
onDismissComposer: () -> Unit,
|
||||||
onSelectFile: () -> Unit,
|
onSelectFile: () -> Unit,
|
||||||
onClearFile: () -> Unit,
|
onClearFile: () -> Unit,
|
||||||
onTransferNameChanged: (String) -> Unit,
|
onTransferNameChanged: (String) -> Unit,
|
||||||
onSenderNameChanged: (String) -> Unit,
|
onSenderNameChanged: (String) -> Unit,
|
||||||
|
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||||
onCreateShare: () -> Unit,
|
onCreateShare: () -> Unit,
|
||||||
|
onTransferSelected: (ULong) -> Unit,
|
||||||
|
onCloseTransferDetails: () -> Unit,
|
||||||
onCopyTicket: (String) -> Unit,
|
onCopyTicket: (String) -> Unit,
|
||||||
onUseTicket: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||||
if (!state.shouldShowEmptyState(coreState)) {
|
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||||
ScreenHeader(stringResource(Res.string.send_title), stringResource(Res.string.send_subtitle))
|
|
||||||
}
|
Box(Modifier.fillMaxSize()) {
|
||||||
if (state.shouldShowEmptyState(coreState)) {
|
if (selectedTransfer != null) {
|
||||||
SendEmptyState(windowClass = windowClass, onCreateNewTransfer = onSelectFile)
|
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
|
||||||
} else {
|
} else {
|
||||||
SourceCard(state, onSelectFile, onClearFile)
|
TransferCatalog(
|
||||||
DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare)
|
transfers = outgoingTransfers,
|
||||||
coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) }
|
transferThumbnails = state.transferThumbnails,
|
||||||
ProgressSection(coreState)
|
windowClass = windowClass,
|
||||||
|
onOpenComposer = onOpenComposer,
|
||||||
|
onTransferSelected = onTransferSelected,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
if (state.isComposerOpen) {
|
||||||
private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) {
|
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
|
||||||
AppCard(title = stringResource(Res.string.source_title)) {
|
TransferComposer(
|
||||||
if (state.selectedSource.isBlank()) {
|
coreInitialized = coreState.isInitialized,
|
||||||
EmptyText(stringResource(Res.string.send_source_empty))
|
state = state,
|
||||||
} else {
|
windowClass = windowClass,
|
||||||
MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') })
|
onSelectFile = onSelectFile,
|
||||||
MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource)
|
onClearFile = onClearFile,
|
||||||
}
|
onTransferNameChanged = onTransferNameChanged,
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
onSenderNameChanged = onSenderNameChanged,
|
||||||
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
|
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||||
SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource)
|
onCreateShare = onCreateShare,
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DetailsCard(
|
|
||||||
coreState: CoreState,
|
|
||||||
state: SendState,
|
|
||||||
onTransferNameChanged: (String) -> Unit,
|
|
||||||
onSenderNameChanged: (String) -> Unit,
|
|
||||||
onCreateShare: () -> Unit,
|
|
||||||
) {
|
|
||||||
AppCard(title = stringResource(Res.string.transfer_details_title)) {
|
|
||||||
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
|
|
||||||
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
|
|
||||||
PrimaryButton(
|
|
||||||
text = if (state.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
|
|
||||||
onClick = onCreateShare,
|
|
||||||
enabled = state.canCreateShare(coreState.isInitialized),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ShareCard(share: Share, onCopyTicket: (String) -> Unit, onUseTicket: (String) -> Unit) {
|
|
||||||
AppCard(
|
|
||||||
title = stringResource(Res.string.share_details_title),
|
|
||||||
trailing = {
|
|
||||||
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
|
||||||
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
|
|
||||||
TicketText(share.ticket)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
|
|
||||||
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseTicket(share.ticket) })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
import com.vnidrop.app.core.FileSystemService
|
import com.vnidrop.app.core.FileSystemService
|
||||||
import com.vnidrop.app.core.PickedShareFile
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.preferences.PreferencesRepository
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
|
import com.vnidrop.app.ui.feedback.UiMessage
|
||||||
import com.vnidrop.app.ui.feedback.UiMessageController
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
|
import com.vnidrop.app.ui.feedback.UiMessageTone
|
||||||
|
import com.vnidrop.app.ui.feedback.UiText
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -14,26 +18,26 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import vnidrop.shared.generated.resources.Res
|
||||||
private const val DefaultTransferName = "VniDrop transfer"
|
import vnidrop.shared.generated.resources.send_transfer_created
|
||||||
|
|
||||||
data class SendState(
|
data class SendState(
|
||||||
val selectedSource: String = "",
|
val isComposerOpen: Boolean = false,
|
||||||
val selectedDisplayName: String = "",
|
val selectedFile: PickedShareFile? = null,
|
||||||
val transferName: String = DefaultTransferName,
|
val transferName: String = "",
|
||||||
val senderName: String = "",
|
val senderName: String = "",
|
||||||
|
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
val isSharing: Boolean = false,
|
val isSharing: Boolean = false,
|
||||||
|
val selectedTransferId: ULong? = null,
|
||||||
|
val transferThumbnails: Map<ULong, ByteArray> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
val hasSelectedSource: Boolean
|
fun canCreateShare(coreInitialized: Boolean): Boolean =
|
||||||
get() = selectedSource.isNotBlank()
|
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing
|
||||||
|
|
||||||
fun canCreateShare(coreInitialized: Boolean): Boolean = coreInitialized && hasSelectedSource && !isSharing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface SendEffect {
|
sealed interface SendEffect {
|
||||||
data object OpenFilePicker : SendEffect
|
data object OpenFilePicker : SendEffect
|
||||||
data class CopyTicket(val ticket: String) : SendEffect
|
data class CopyTicket(val ticket: String) : SendEffect
|
||||||
data class UseTicket(val ticket: String) : SendEffect
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SendViewModel(
|
class SendViewModel(
|
||||||
@@ -48,7 +52,6 @@ class SendViewModel(
|
|||||||
|
|
||||||
private val effects = Channel<SendEffect>(Channel.BUFFERED)
|
private val effects = Channel<SendEffect>(Channel.BUFFERED)
|
||||||
val effectFlow = effects.receiveAsFlow()
|
val effectFlow = effects.receiveAsFlow()
|
||||||
private var selectedFile: PickedShareFile? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -60,15 +63,38 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openComposer() {
|
||||||
|
if (_state.value.isSharing) return
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isComposerOpen = true,
|
||||||
|
selectedFile = null,
|
||||||
|
transferName = "",
|
||||||
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissComposer() {
|
||||||
|
if (_state.value.isSharing) return
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isComposerOpen = false,
|
||||||
|
selectedFile = null,
|
||||||
|
transferName = "",
|
||||||
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
|
||||||
|
|
||||||
fun onFilePicked(file: PickedShareFile) {
|
fun onFilePicked(file: PickedShareFile) {
|
||||||
selectedFile = file
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedSource = file.value,
|
isComposerOpen = true,
|
||||||
selectedDisplayName = file.displayName,
|
selectedFile = file,
|
||||||
transferName = if (it.transferName.isBlank() || it.transferName == DefaultTransferName) file.displayName else it.transferName,
|
transferName = file.displayName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,33 +102,54 @@ class SendViewModel(
|
|||||||
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
|
||||||
|
|
||||||
fun clearSelectedSource() {
|
fun clearSelectedSource() {
|
||||||
selectedFile = null
|
_state.update { it.copy(selectedFile = null, transferName = "") }
|
||||||
_state.update { it.copy(selectedSource = "", selectedDisplayName = "") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
|
||||||
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
|
fun setSenderName(value: String) = _state.update { it.copy(senderName = value) }
|
||||||
|
fun setAccessPolicy(value: ShareAccessPolicy) = _state.update { it.copy(accessPolicy = value) }
|
||||||
|
fun openTransfer(transferId: ULong) = _state.update { it.copy(selectedTransferId = transferId) }
|
||||||
|
fun closeTransferDetails() = _state.update { it.copy(selectedTransferId = null) }
|
||||||
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
|
fun copyTicket(ticket: String) = sendEffect(SendEffect.CopyTicket(ticket))
|
||||||
fun useTicket(ticket: String) = sendEffect(SendEffect.UseTicket(ticket))
|
|
||||||
|
|
||||||
fun createShare() {
|
fun createShare() {
|
||||||
val current = state.value
|
val current = state.value
|
||||||
|
val file = current.selectedFile ?: return
|
||||||
if (!current.canCreateShare(coreState.value.isInitialized)) return
|
if (!current.canCreateShare(coreState.value.isInitialized)) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isSharing = true) }
|
_state.update { it.copy(isSharing = true) }
|
||||||
try {
|
val result = fileSystemService.sharePickedFile(
|
||||||
val result = selectedFile?.let { file ->
|
repository = repository,
|
||||||
fileSystemService.sharePickedFile(repository, file, current.transferName, current.senderName)
|
file = file,
|
||||||
} ?: repository.sharePath(current.selectedSource, current.transferName, current.senderName)
|
transferName = current.transferName.trim(),
|
||||||
result.onFailure(messages::error)
|
senderName = current.senderName.trim(),
|
||||||
} finally {
|
accessPolicy = current.accessPolicy,
|
||||||
_state.update { it.copy(isSharing = false) }
|
)
|
||||||
}
|
result.fold(
|
||||||
|
onSuccess = { share ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isComposerOpen = false,
|
||||||
|
selectedFile = null,
|
||||||
|
transferName = "",
|
||||||
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
|
isSharing = false,
|
||||||
|
transferThumbnails = file.thumbnailBytes?.let { bytes ->
|
||||||
|
it.transferThumbnails + (share.transferId to bytes)
|
||||||
|
} ?: it.transferThumbnails,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
_state.update { it.copy(isSharing = false) }
|
||||||
|
messages.error(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendEffect(effect: SendEffect) {
|
private fun sendEffect(effect: SendEffect) {
|
||||||
viewModelScope.launch { effects.send(effect) }
|
viewModelScope.launch { effects.send(effect) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.CoreEventModel
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
enum class WindowClass {
|
enum class WindowClass {
|
||||||
@@ -27,19 +28,19 @@ data class TransferProgress(
|
|||||||
val progress: Float?,
|
val progress: Float?,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun displayNameForStatus(status: String): String =
|
fun displayNameForStatus(status: TransferStatus): String =
|
||||||
when (status.lowercase()) {
|
when (status) {
|
||||||
"sharing" -> "Sharing"
|
TransferStatus.Importing -> "Preparing"
|
||||||
"receiving" -> "Receiving"
|
TransferStatus.Sharing -> "Available"
|
||||||
"done" -> "Done"
|
TransferStatus.Receiving -> "Receiving"
|
||||||
"cancelled" -> "Cancelled"
|
TransferStatus.Done -> "Completed"
|
||||||
"stopped" -> "Stopped"
|
TransferStatus.Cancelled -> "Cancelled"
|
||||||
"failed" -> "Failed"
|
TransferStatus.Stopped -> "Stopped"
|
||||||
else -> status.replaceFirstChar { it.uppercase() }
|
TransferStatus.Failed -> "Failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Transfer.isActiveTransfer(): Boolean =
|
fun Transfer.isActiveTransfer(): Boolean =
|
||||||
status.lowercase() in activeTransferStatuses
|
status in activeTransferStatuses
|
||||||
|
|
||||||
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||||
events
|
events
|
||||||
@@ -81,7 +82,7 @@ fun formatBytes(size: ULong): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
|
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
|
||||||
private val activeTransferStatuses = setOf("importing", "sharing", "receiving")
|
private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
|
||||||
|
|
||||||
private fun eventLabel(event: CoreEventModel): String {
|
private fun eventLabel(event: CoreEventModel): String {
|
||||||
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.vnidrop.app.feature
|
|||||||
|
|
||||||
import com.vnidrop.app.DeviceInfo
|
import com.vnidrop.app.DeviceInfo
|
||||||
import com.vnidrop.app.PlatformEnvironment
|
import com.vnidrop.app.PlatformEnvironment
|
||||||
|
import com.vnidrop.app.core.CoreState
|
||||||
import com.vnidrop.app.core.ReceiveFolder
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
import com.vnidrop.app.core.ReceiveFolderKind
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
|
import com.vnidrop.app.core.Share
|
||||||
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.feature.app.AppViewModel
|
import com.vnidrop.app.feature.app.AppViewModel
|
||||||
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
||||||
import com.vnidrop.app.feature.send.SendViewModel
|
import com.vnidrop.app.feature.send.SendViewModel
|
||||||
@@ -139,11 +142,49 @@ class ViewModelsTest {
|
|||||||
fun sendViewModelOwnsSelectedFileState() = runTest {
|
fun sendViewModelOwnsSelectedFileState() = runTest {
|
||||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController())
|
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController())
|
||||||
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg"))
|
viewModel.openComposer()
|
||||||
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
||||||
assertEquals("photo.jpg", viewModel.state.value.transferName)
|
assertEquals("photo.jpg", viewModel.state.value.transferName)
|
||||||
assertTrue(viewModel.state.value.hasSelectedSource)
|
assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
|
||||||
viewModel.clearSelectedSource()
|
viewModel.clearSelectedSource()
|
||||||
assertFalse(viewModel.state.value.hasSelectedSource)
|
assertEquals(null, viewModel.state.value.selectedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendComposerClosesAfterSuccessfulAtomicShareCreation() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = CoreState(isInitialized = true)
|
||||||
|
shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL))
|
||||||
|
}
|
||||||
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.openComposer()
|
||||||
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
||||||
|
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
|
||||||
|
viewModel.createShare()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertFalse(viewModel.state.value.isComposerOpen)
|
||||||
|
assertEquals(null, viewModel.state.value.selectedFile)
|
||||||
|
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
|
||||||
|
assertEquals(7UL, core.state.value.transfers.first().transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway().apply { mutableState.value = CoreState(isInitialized = true) }
|
||||||
|
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.openComposer()
|
||||||
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))
|
||||||
|
viewModel.createShare()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.state.value.isComposerOpen)
|
||||||
|
assertEquals("photo.jpg", viewModel.state.value.selectedFile?.displayName)
|
||||||
|
assertFalse(viewModel.state.value.isSharing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import com.vnidrop.app.core.PickedShareFile
|
|||||||
import com.vnidrop.app.core.ReceiveFolder
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
import com.vnidrop.app.core.ReceiverRequestModel
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
import com.vnidrop.app.core.Share
|
import com.vnidrop.app.core.Share
|
||||||
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.TicketInspectionModel
|
import com.vnidrop.app.core.TicketInspectionModel
|
||||||
|
import com.vnidrop.app.core.Transfer
|
||||||
|
import com.vnidrop.app.core.TransferDirection
|
||||||
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.notifications.LocalNotification
|
import com.vnidrop.app.notifications.LocalNotification
|
||||||
import com.vnidrop.app.notifications.LocalNotificationService
|
import com.vnidrop.app.notifications.LocalNotificationService
|
||||||
import com.vnidrop.app.notifications.NotificationPermission
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
@@ -30,15 +34,53 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>()
|
val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>()
|
||||||
var responseResult: Result<Unit> = Result.success(Unit)
|
var responseResult: Result<Unit> = Result.success(Unit)
|
||||||
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
||||||
|
var shareResult: Result<Share> = Result.failure(UnsupportedOperationException())
|
||||||
|
var lastShareAccessPolicy: ShareAccessPolicy? = null
|
||||||
|
|
||||||
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
||||||
mutableState.value = mutableState.value.copy(isInitialized = true)
|
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
override fun shutdown() = Unit
|
override fun shutdown() = Unit
|
||||||
override suspend fun sharePath(path: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> {
|
||||||
override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
lastShareAccessPolicy = accessPolicy
|
||||||
override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
shareResult.onSuccess { share ->
|
||||||
|
mutableState.value = mutableState.value.copy(
|
||||||
|
transfers = listOf(
|
||||||
|
Transfer(
|
||||||
|
localId = "local-${share.transferId}",
|
||||||
|
transferId = share.transferId,
|
||||||
|
direction = TransferDirection.Send,
|
||||||
|
status = TransferStatus.Sharing,
|
||||||
|
peerId = null,
|
||||||
|
transferName = share.transferName,
|
||||||
|
contentHash = share.contentHash,
|
||||||
|
fileCount = share.fileCount,
|
||||||
|
totalSize = share.totalSize,
|
||||||
|
ticket = share.ticket,
|
||||||
|
accessPolicy = accessPolicy,
|
||||||
|
createdAt = 1L,
|
||||||
|
updatedAt = 1L,
|
||||||
|
),
|
||||||
|
) + mutableState.value.transfers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return shareResult
|
||||||
|
}
|
||||||
|
override suspend fun shareFileDescriptor(
|
||||||
|
fd: Int,
|
||||||
|
displayName: String,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
|
override suspend fun shareSecurityScopedFileUrl(
|
||||||
|
fileUrl: String,
|
||||||
|
displayName: String,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException())
|
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException())
|
||||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit)
|
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit)
|
||||||
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
||||||
@@ -93,5 +135,6 @@ class FakeFileSystemService(
|
|||||||
file: PickedShareFile,
|
file: PickedShareFile,
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
) = repository.sharePath(file.value, transferName, senderName)
|
accessPolicy: ShareAccessPolicy,
|
||||||
|
) = repository.sharePath(file.value, transferName, senderName, accessPolicy)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package com.vnidrop.app.ui.state
|
|||||||
import com.vnidrop.app.feature.receive.ReceiveState
|
import com.vnidrop.app.feature.receive.ReceiveState
|
||||||
import com.vnidrop.app.feature.send.SendState
|
import com.vnidrop.app.feature.send.SendState
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
|
import com.vnidrop.app.core.TransferDirection
|
||||||
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.ui.theme.ThemeMode
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
import com.vnidrop.app.ui.theme.resolveDarkTheme
|
import com.vnidrop.app.ui.theme.resolveDarkTheme
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
@@ -41,7 +45,10 @@ class AppUiModelsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendStateExposesShareEligibility() {
|
fun sendStateExposesShareEligibility() {
|
||||||
val ready = SendState(selectedSource = "/tmp/payload.txt")
|
val ready = SendState(
|
||||||
|
selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL),
|
||||||
|
transferName = "payload.txt",
|
||||||
|
)
|
||||||
|
|
||||||
assertTrue(ready.canCreateShare(coreInitialized = true))
|
assertTrue(ready.canCreateShare(coreInitialized = true))
|
||||||
assertFalse(ready.canCreateShare(coreInitialized = false))
|
assertFalse(ready.canCreateShare(coreInitialized = false))
|
||||||
@@ -67,24 +74,28 @@ class AppUiModelsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun transferActivityOnlyIncludesRunningStatuses() {
|
fun transferActivityOnlyIncludesRunningStatuses() {
|
||||||
assertTrue(storedTransfer(status = "importing").isActiveTransfer())
|
assertTrue(storedTransfer(status = TransferStatus.Importing).isActiveTransfer())
|
||||||
assertTrue(storedTransfer(status = "sharing").isActiveTransfer())
|
assertTrue(storedTransfer(status = TransferStatus.Sharing).isActiveTransfer())
|
||||||
assertTrue(storedTransfer(status = "receiving").isActiveTransfer())
|
assertTrue(storedTransfer(status = TransferStatus.Receiving).isActiveTransfer())
|
||||||
assertFalse(storedTransfer(status = "done").isActiveTransfer())
|
assertFalse(storedTransfer(status = TransferStatus.Done).isActiveTransfer())
|
||||||
assertFalse(storedTransfer(status = "failed").isActiveTransfer())
|
assertFalse(storedTransfer(status = TransferStatus.Failed).isActiveTransfer())
|
||||||
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer())
|
assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun storedTransfer(status: String): Transfer =
|
private fun storedTransfer(status: TransferStatus): Transfer =
|
||||||
Transfer(
|
Transfer(
|
||||||
localId = "local-1",
|
localId = "local-1",
|
||||||
transferId = 1UL,
|
transferId = 1UL,
|
||||||
peerId = null,
|
peerId = null,
|
||||||
direction = "send",
|
direction = TransferDirection.Send,
|
||||||
status = status,
|
status = status,
|
||||||
transferName = "Demo",
|
transferName = "Demo",
|
||||||
|
contentHash = "hash",
|
||||||
ticket = null,
|
ticket = null,
|
||||||
fileCount = 1UL,
|
fileCount = 1UL,
|
||||||
totalSize = 128UL,
|
totalSize = 128UL,
|
||||||
|
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||||
|
createdAt = 1L,
|
||||||
|
updatedAt = 1L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package com.vnidrop.app.core
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.readBytes
|
||||||
import platform.Foundation.NSURL
|
import platform.Foundation.NSURL
|
||||||
|
import platform.Foundation.NSFileManager
|
||||||
|
import platform.Foundation.NSFileSize
|
||||||
|
import platform.Foundation.NSNumber
|
||||||
import platform.UIKit.UIApplication
|
import platform.UIKit.UIApplication
|
||||||
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
import platform.UIKit.UIDocumentPickerDelegateProtocol
|
||||||
import platform.UIKit.UIDocumentPickerViewController
|
import platform.UIKit.UIDocumentPickerViewController
|
||||||
|
import platform.UIKit.UIDocumentInteractionController
|
||||||
|
import platform.UIKit.UIImage
|
||||||
|
import platform.UIKit.UIImagePNGRepresentation
|
||||||
import platform.UIKit.UIModalPresentationFormSheet
|
import platform.UIKit.UIModalPresentationFormSheet
|
||||||
import platform.UniformTypeIdentifiers.UTTypeFolder
|
import platform.UniformTypeIdentifiers.UTTypeFolder
|
||||||
import platform.UniformTypeIdentifiers.UTTypeItem
|
import platform.UniformTypeIdentifiers.UTTypeItem
|
||||||
@@ -83,7 +90,21 @@ private class DocumentPickerDelegate(
|
|||||||
onError("The selected iOS document URL was invalid")
|
onError("The selected iOS document URL was invalid")
|
||||||
} else {
|
} else {
|
||||||
val displayName = url.lastPathComponent ?: "transfer"
|
val displayName = url.lastPathComponent ?: "transfer"
|
||||||
onFilePicked(PickedShareFile(url.absoluteString ?: url.path.orEmpty(), displayName))
|
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||||
|
val sizeBytes = try {
|
||||||
|
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
||||||
|
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
||||||
|
} finally {
|
||||||
|
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||||
|
}
|
||||||
|
onFilePicked(
|
||||||
|
PickedShareFile(
|
||||||
|
url.absoluteString ?: url.path.orEmpty(),
|
||||||
|
displayName,
|
||||||
|
sizeBytes,
|
||||||
|
nativeFileIcon(url),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
retainedPickerDelegate = null
|
retainedPickerDelegate = null
|
||||||
}
|
}
|
||||||
@@ -92,3 +113,11 @@ private class DocumentPickerDelegate(
|
|||||||
retainedPickerDelegate = null
|
retainedPickerDelegate = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching {
|
||||||
|
val controller = UIDocumentInteractionController.interactionControllerWithURL(url)
|
||||||
|
val icon = controller.icons.lastOrNull() as? UIImage ?: return null
|
||||||
|
val data = UIImagePNGRepresentation(icon) ?: return null
|
||||||
|
data.bytes?.readBytes(data.length.toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import java.awt.EventQueue
|
|||||||
import java.awt.FileDialog
|
import java.awt.FileDialog
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
import java.awt.KeyboardFocusManager
|
import java.awt.KeyboardFocusManager
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import javax.imageio.ImageIO
|
||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
|
import javax.swing.filechooser.FileSystemView
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberShareFilePicker(
|
actual fun rememberShareFilePicker(
|
||||||
@@ -75,7 +79,12 @@ private fun pickShareFile(): PickedShareFile? {
|
|||||||
val file = dialog.file
|
val file = dialog.file
|
||||||
if (directory != null && file != null) {
|
if (directory != null && file != null) {
|
||||||
val selected = File(directory, file)
|
val selected = File(directory, file)
|
||||||
PickedShareFile(selected.absolutePath, selected.name)
|
PickedShareFile(
|
||||||
|
selected.absolutePath,
|
||||||
|
selected.name,
|
||||||
|
selected.length().takeIf { it >= 0L }?.toULong(),
|
||||||
|
selected.systemIconPng(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -84,6 +93,21 @@ private fun pickShareFile(): PickedShareFile? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun File.systemIconPng(): ByteArray? = runCatching {
|
||||||
|
val icon = FileSystemView.getFileSystemView().getSystemIcon(this, 128, 128)
|
||||||
|
val image = BufferedImage(icon.iconWidth, icon.iconHeight, BufferedImage.TYPE_INT_ARGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
icon.paintIcon(null, graphics, 0, 0)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream().use { output ->
|
||||||
|
ImageIO.write(image, "png", output)
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
private fun pickDirectory(): File? =
|
private fun pickDirectory(): File? =
|
||||||
if (isMacOs()) {
|
if (isMacOs()) {
|
||||||
val dialog = withMacDirectoryDialog {
|
val dialog = withMacDirectoryDialog {
|
||||||
|
|||||||
Reference in New Issue
Block a user