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:
@@ -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)
|
||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (selectedTransfer != null) {
|
||||
TransferDetails(selectedTransfer, onCloseTransferDetails, onCopyTicket)
|
||||
} else {
|
||||
SourceCard(state, onSelectFile, onClearFile)
|
||||
DetailsCard(coreState, state, onTransferNameChanged, onSenderNameChanged, onCreateShare)
|
||||
coreState.lastShare?.let { ShareCard(it, onCopyTicket, onUseTicket) }
|
||||
ProgressSection(coreState)
|
||||
TransferCatalog(
|
||||
transfers = outgoingTransfers,
|
||||
transferThumbnails = state.transferThumbnails,
|
||||
windowClass = windowClass,
|
||||
onOpenComposer = onOpenComposer,
|
||||
onTransferSelected = onTransferSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceCard(state: SendState, onSelectFile: () -> Unit, onClearFile: () -> Unit) {
|
||||
AppCard(title = stringResource(Res.string.source_title)) {
|
||||
if (state.selectedSource.isBlank()) {
|
||||
EmptyText(stringResource(Res.string.send_source_empty))
|
||||
} else {
|
||||
MetadataRow(stringResource(Res.string.metadata_name), state.selectedDisplayName.ifBlank { state.selectedSource.substringAfterLast('/') })
|
||||
MetadataRow(stringResource(Res.string.metadata_source), state.selectedSource)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(stringResource(Res.string.button_select_file), onClick = onSelectFile)
|
||||
SecondaryButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = state.hasSelectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailsCard(
|
||||
coreState: CoreState,
|
||||
state: SendState,
|
||||
onTransferNameChanged: (String) -> Unit,
|
||||
onSenderNameChanged: (String) -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
) {
|
||||
AppCard(title = stringResource(Res.string.transfer_details_title)) {
|
||||
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
|
||||
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
|
||||
PrimaryButton(
|
||||
text = if (state.isSharing) stringResource(Res.string.button_creating_share) else stringResource(Res.string.button_create_share),
|
||||
onClick = onCreateShare,
|
||||
enabled = state.canCreateShare(coreState.isInitialized),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareCard(share: Share, onCopyTicket: (String) -> Unit, onUseTicket: (String) -> Unit) {
|
||||
AppCard(
|
||||
title = stringResource(Res.string.share_details_title),
|
||||
trailing = {
|
||||
StatusPill("${share.fileCount} file${if (share.fileCount == 1UL) "" else "s"}", tone = PillTone.Brand)
|
||||
},
|
||||
) {
|
||||
MetadataRow(stringResource(Res.string.metadata_transfer), share.transferName)
|
||||
MetadataRow(stringResource(Res.string.metadata_size), formatBytes(share.totalSize))
|
||||
TicketText(share.ticket)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryButton(stringResource(Res.string.button_copy), onClick = { onCopyTicket(share.ticket) })
|
||||
SecondaryButton(stringResource(Res.string.button_use_locally), onClick = { onUseTicket(share.ticket) })
|
||||
if (state.isComposerOpen) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissComposer) {
|
||||
TransferComposer(
|
||||
coreInitialized = coreState.isInitialized,
|
||||
state = state,
|
||||
windowClass = windowClass,
|
||||
onSelectFile = onSelectFile,
|
||||
onClearFile = onClearFile,
|
||||
onTransferNameChanged = onTransferNameChanged,
|
||||
onSenderNameChanged = onSenderNameChanged,
|
||||
onAccessPolicyChanged = onAccessPolicyChanged,
|
||||
onCreateShare = onCreateShare,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user