feat(receive): add adaptive receive flow and document opening

This commit is contained in:
2026-07-12 04:15:21 +02:00
parent 757966379b
commit 996cc0a5ab
30 changed files with 1203 additions and 92 deletions

View File

@@ -79,6 +79,30 @@
<string name="button_refuse">Refuse</string>
<string name="button_approve">Approve</string>
<string name="receive_title">Receive</string>
<string name="receive_new_subtitle">Files received directly on this device.</string>
<string name="receive_empty_title">Receive your first file</string>
<string name="receive_empty_body">Open a VniDrop invitation, scan its QR code, or read a nearby NFC tag.</string>
<string name="button_receive_files">Receive files</string>
<string name="receive_history_title">Received files</string>
<string name="receive_clear_history">Clear history</string>
<string name="receive_delete_history_item">Delete from receive history</string>
<string name="receive_delete_history_title">Remove from history?</string>
<string name="receive_delete_history_description">“%1$s” will be removed from VniDrops history. The downloaded file will remain on this device.</string>
<string name="receive_clear_history_title">Clear receive history?</string>
<string name="receive_clear_history_description">All completed, failed, and cancelled receives will be removed from VniDrops history. Downloaded files will remain on this device.</string>
<string name="receive_history_cleared">Receive history cleared.</string>
<string name="receive_choose_method_title">How would you like to connect?</string>
<string name="receive_choose_method_body">Choose the invitation method available to you.</string>
<string name="receive_method_file">Open a .vnd invitation</string>
<string name="receive_method_file_description">Choose an invitation saved or shared to this device.</string>
<string name="receive_method_scan">Scan QR code</string>
<string name="receive_method_scan_description">Use the camera to scan the senders VniDrop code.</string>
<string name="receive_method_nfc">Read NFC tag</string>
<string name="receive_method_nfc_description">Hold this device near the senders invitation tag.</string>
<string name="receive_nfc_waiting">Hold near the NFC tag…</string>
<string name="receive_review_title">Review transfer</string>
<string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="receive_completed">Transfer received.</string>
<string name="receive_subtitle">Inspect a ticket, request access, and stream files into the output directory.</string>
<string name="ticket_card_title">Ticket</string>
<string name="field_ticket">Ticket</string>

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -19,7 +20,9 @@ import com.vnidrop.app.feature.app.AppViewModel
import com.vnidrop.app.feature.app.AppGraphViewModel
import com.vnidrop.app.feature.approvals.ApprovalModalHost
import com.vnidrop.app.feature.receive.ReceiveRoute
import com.vnidrop.app.feature.receive.ReceiveFloatingAction
import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.receive.ReceiveMethod
import com.vnidrop.app.feature.send.SendRoute
import com.vnidrop.app.feature.send.SendFloatingAction
import com.vnidrop.app.feature.send.SendViewModel
@@ -35,6 +38,8 @@ import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.state.windowClassFor
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@Composable
fun App(dependencies: AppDependencies) {
@@ -69,8 +74,22 @@ fun App(dependencies: AppDependencies) {
val appState by appViewModel.state.collectAsStateWithLifecycle()
val sendState by sendViewModel.state.collectAsStateWithLifecycle()
val sendCoreState by sendViewModel.coreState.collectAsStateWithLifecycle()
val receiveState by receiveViewModel.state.collectAsStateWithLifecycle()
val receiveCoreState by receiveViewModel.coreState.collectAsStateWithLifecycle()
val approvalState by graph.approvalCoordinator.state.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(dependencies.externalInvitations, appViewModel, receiveViewModel) {
dependencies.externalInvitations.invitations.collect { invitation ->
appViewModel.selectDestination(AppDestination.Receive)
if (invitation.isSuccess) {
receiveViewModel.coreState.filter { it.isInitialized }.first()
receiveViewModel.state.filter { state ->
!state.isInspecting && !state.isReceiving && state.ticket.isBlank()
}.first()
}
receiveViewModel.onInvitationResult(ReceiveMethod.InvitationFile, invitation)
}
}
DisposableEffect(lifecycleOwner, graph, settingsViewModel) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -97,6 +116,10 @@ fun App(dependencies: AppDependencies) {
sendCoreState.transfers.any { it.transferId == selectedId }
} != true &&
sendCoreState.transfers.any { it.direction == TransferDirection.Send }
val showReceiveAction = appState.destination == AppDestination.Receive &&
windowClass == WindowClass.Phone &&
!receiveState.isAcquisitionOpen &&
receiveCoreState.transfers.any { it.direction == TransferDirection.Receive }
AppShell(
modifier = Modifier.fillMaxSize(),
selectedDestination = appState.destination,
@@ -112,13 +135,20 @@ fun App(dependencies: AppDependencies) {
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else if (showReceiveAction) {
{
ReceiveFloatingAction(
onClick = receiveViewModel::openAcquisition,
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
)
}
} else {
null
},
) {
when (appState.destination) {
AppDestination.Send -> SendRoute(sendViewModel, windowClass)
AppDestination.Receive -> ScreenScrollContainer { ReceiveRoute(receiveViewModel) }
AppDestination.Receive -> ReceiveRoute(receiveViewModel, windowClass)
AppDestination.Settings -> ScreenScrollContainer { SettingsRoute(settingsViewModel, windowClass) }
}
}

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app
import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.notifications.LocalNotificationService
import com.vnidrop.app.feature.receive.ExternalInvitationController
data class PlatformEnvironment(
val name: String,
@@ -27,4 +28,5 @@ data class AppDependencies(
val deviceInfoProvider: DeviceInfoProvider,
val fileSystemService: FileSystemService,
val localNotificationService: LocalNotificationService,
val externalInvitations: ExternalInvitationController,
)

View File

@@ -0,0 +1,36 @@
package com.vnidrop.app.feature.receive
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
const val VniDropInvitationMimeType = "application/vnd.vnidrop.transfer"
const val VniDropInvitationExtension = "vnd"
const val MaxVniDropInvitationBytes = 64 * 1024
/**
* Buffered ingress for invitation documents opened by a platform host.
*
* Hosts can submit before Compose is attached during a cold launch. Each
* document is then consumed exactly once by the app-level receive workflow.
*/
class ExternalInvitationController {
// OS document-open dispatch can be triggered by another process. Keep the
// cold-launch queue bounded so repeated intents cannot grow memory forever.
private val pending = Channel<Result<String>>(capacity = 16)
val invitations: Flow<Result<String>> = pending.receiveAsFlow()
fun openInvitation(raw: String) {
pending.trySend(validateInvitation(raw))
}
fun reportOpenFailure(message: String) {
pending.trySend(Result.failure(IllegalArgumentException(message)))
}
}
internal fun validateInvitation(raw: String): Result<String> = runCatching {
require(raw.isNotBlank()) { "The invitation is empty" }
require(raw.encodeToByteArray().size <= MaxVniDropInvitationBytes) { "The invitation is too large" }
raw
}

View File

@@ -0,0 +1,24 @@
package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable
enum class ReceiveMethod { InvitationFile, QrCode, Nfc }
enum class ReceiveMethodAvailability { Available, Unavailable, Hidden }
interface ReceiveInvitationActions {
val fileAvailability: ReceiveMethodAvailability
val qrAvailability: ReceiveMethodAvailability
val nfcAvailability: ReceiveMethodAvailability
fun pickInvitation(onResult: (Result<String>) -> Unit)
fun scanQrCode(onResult: (Result<String>) -> Unit)
fun readNfcInvitation(onResult: (Result<String>) -> Unit)
fun cancel()
}
@Composable
expect fun rememberReceiveInvitationActions(): ReceiveInvitationActions
internal const val InvitationMimeType = VniDropInvitationMimeType
internal const val MaxInvitationBytes = MaxVniDropInvitationBytes

View File

@@ -1,19 +1,35 @@
package com.vnidrop.app.feature.receive
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vnidrop.app.ui.state.WindowClass
@Composable
fun ReceiveRoute(viewModel: ReceiveViewModel) {
fun ReceiveRoute(viewModel: ReceiveViewModel, windowClass: WindowClass) {
val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val actions = rememberReceiveInvitationActions()
DisposableEffect(actions) { onDispose(actions::cancel) }
ReceiveScreen(
coreState = coreState,
state = state,
onTicketChanged = viewModel::setTicket,
windowClass = windowClass,
actions = actions,
onOpenAcquisition = viewModel::openAcquisition,
onDismissAcquisition = {
actions.cancel()
viewModel.dismissAcquisition()
},
onReceiverNameChanged = viewModel::setReceiverName,
onInspectTicket = viewModel::inspectTicket,
onInvitationResult = viewModel::onInvitationResult,
onWaitingForNfc = viewModel::setWaitingForNfc,
onReceive = viewModel::receive,
onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem,
onRequestClearHistory = viewModel::requestClearHistory,
onDismissHistoryDelete = viewModel::dismissHistoryDelete,
onConfirmHistoryDelete = viewModel::confirmHistoryDelete,
)
}

View File

@@ -1,76 +1,323 @@
package com.vnidrop.app.feature.receive
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.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.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.CoreState
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.ui.components.AppCard
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.DestructiveQuietButton
import com.vnidrop.app.ui.components.Field
import com.vnidrop.app.ui.components.MetadataRow
import com.vnidrop.app.ui.components.PrimaryButton
import com.vnidrop.app.ui.components.SecondaryButton
import com.vnidrop.app.ui.screens.ProgressSection
import com.vnidrop.app.ui.screens.ScreenHeader
import com.vnidrop.app.ui.screens.TicketInspectionCard
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 vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_inspect_ticket
import vnidrop.shared.generated.resources.button_receive
import vnidrop.shared.generated.resources.button_receiving
import vnidrop.shared.generated.resources.field_output_directory
import vnidrop.shared.generated.resources.field_receiver_name
import vnidrop.shared.generated.resources.field_ticket
import vnidrop.shared.generated.resources.folder_status_permission_required
import vnidrop.shared.generated.resources.folder_status_unavailable
import vnidrop.shared.generated.resources.folder_status_writable
import vnidrop.shared.generated.resources.metadata_status
import vnidrop.shared.generated.resources.receive_subtitle
import vnidrop.shared.generated.resources.receive_title
import vnidrop.shared.generated.resources.ticket_card_title
import vnidrop.shared.generated.resources.*
@Composable
fun ReceiveFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
FloatingActionButton(
onClick = onClick,
modifier = modifier,
containerColor = LocalVniDropColors.current.brandButton,
contentColor = Color.White,
) { Icon(ReceiveIcons.Download, stringResource(Res.string.button_receive_files)) }
}
@Composable
fun ReceiveScreen(
coreState: CoreState,
state: ReceiveState,
onTicketChanged: (String) -> Unit,
windowClass: WindowClass,
actions: ReceiveInvitationActions,
onOpenAcquisition: () -> Unit,
onDismissAcquisition: () -> Unit,
onReceiverNameChanged: (String) -> Unit,
onInspectTicket: () -> Unit,
onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit,
onReceive: () -> Unit,
onRequestDeleteHistoryItem: (ULong) -> Unit,
onRequestClearHistory: () -> Unit,
onDismissHistoryDelete: () -> Unit,
onConfirmHistoryDelete: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
ScreenHeader(stringResource(Res.string.receive_title), stringResource(Res.string.receive_subtitle))
AppCard(title = stringResource(Res.string.ticket_card_title)) {
Field(state.ticket, onTicketChanged, stringResource(Res.string.field_ticket), minLines = 4)
MetadataRow(
stringResource(Res.string.field_output_directory),
state.receiveFolder?.displayName?.ifBlank { state.outputDirectory } ?: state.outputDirectory,
)
MetadataRow(stringResource(Res.string.metadata_status), state.folderAccessStatus.displayName())
Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SecondaryButton(
stringResource(Res.string.button_inspect_ticket),
onClick = onInspectTicket,
enabled = state.canInspect(coreState.isInitialized),
val transfers = coreState.transfers.filter { it.direction == TransferDirection.Receive }
val deletableTransfers = transfers.filter { it.status.isTerminalReceiveHistory() }
LazyColumn(
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { ReceiveHeader(transfers.isNotEmpty(), windowClass, onOpenAcquisition) }
if (transfers.isEmpty()) item { ReceiveEmptyState(onOpenAcquisition) }
else {
item {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(Res.string.receive_history_title), modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (deletableTransfers.isNotEmpty()) DestructiveQuietButton(stringResource(Res.string.receive_clear_history), onClick = onRequestClearHistory)
}
}
items(transfers, key = Transfer::localId) { transfer ->
ReceiveTransferRow(transfer, onDelete = { onRequestDeleteHistoryItem(transfer.transferId) })
}
}
}
if (state.isAcquisitionOpen) {
AdaptiveDrawer(windowClass, onDismissAcquisition) {
if (state.ticket.isBlank()) {
ReceiveMethodPanel(
actions = actions,
isWaitingForNfc = state.isWaitingForNfc,
onResult = onInvitationResult,
onWaitingForNfc = onWaitingForNfc,
)
PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
enabled = state.canReceive(coreState.isInitialized),
} else {
InvitationReviewPanel(
state = state,
coreInitialized = coreState.isInitialized,
onReceiverNameChanged = onReceiverNameChanged,
onReceive = onReceive,
)
}
}
coreState.lastInspection?.let { TicketInspectionCard(it) }
ProgressSection(coreState)
}
state.historyDeleteTarget?.let { target ->
val transferName = (target as? ReceiveHistoryDeleteTarget.Transfer)?.let { selected ->
transfers.firstOrNull { it.transferId == selected.transferId }?.transferName
}
AdaptiveDrawer(windowClass, onDismissHistoryDelete) {
ReceiveHistoryDeletePanel(
clearAll = target == ReceiveHistoryDeleteTarget.All,
transferName = transferName,
isDeleting = state.isDeletingHistory,
onCancel = onDismissHistoryDelete,
onConfirm = onConfirmHistoryDelete,
)
}
}
}
@Composable
private fun FolderAccessStatus.displayName(): String = when (this) {
FolderAccessStatus.Writable -> stringResource(Res.string.folder_status_writable)
FolderAccessStatus.PermissionRequired -> stringResource(Res.string.folder_status_permission_required)
FolderAccessStatus.Unavailable -> stringResource(Res.string.folder_status_unavailable)
private fun ReceiveHeader(showAction: Boolean, windowClass: WindowClass, onOpen: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(Res.string.receive_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text(stringResource(Res.string.receive_new_subtitle), color = LocalVniDropColors.current.foregroundLighter)
}
if (showAction && windowClass != WindowClass.Phone) {
Spacer(Modifier.width(16.dp))
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen)
}
}
}
@Composable
private fun ReceiveEmptyState(onOpen: () -> Unit) {
val colors = LocalVniDropColors.current
Column(
Modifier.fillMaxWidth().heightIn(min = 430.dp).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(68.dp).background(colors.brandLink.copy(alpha = 0.12f), RoundedCornerShape(20.dp)), contentAlignment = Alignment.Center) {
Icon(ReceiveIcons.Download, null, tint = colors.brandLink, modifier = Modifier.size(30.dp))
}
Text(stringResource(Res.string.receive_empty_title), modifier = Modifier.padding(top = 22.dp), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(
stringResource(Res.string.receive_empty_body),
modifier = Modifier.padding(top = 8.dp).widthIn(max = 480.dp),
color = colors.foregroundLighter,
textAlign = TextAlign.Center,
)
PrimaryButton(stringResource(Res.string.button_receive_files), onClick = onOpen, modifier = Modifier.padding(top = 22.dp))
}
}
@Composable
private fun ReceiveMethodPanel(
actions: ReceiveInvitationActions,
isWaitingForNfc: Boolean,
onResult: (ReceiveMethod, Result<String>) -> Unit,
onWaitingForNfc: (Boolean) -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(Res.string.receive_choose_method_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(stringResource(Res.string.receive_choose_method_body), color = LocalVniDropColors.current.foregroundLighter)
ReceiveMethodRow(
ReceiveIcons.File, stringResource(Res.string.receive_method_file), stringResource(Res.string.receive_method_file_description),
actions.fileAvailability,
) { actions.pickInvitation { onResult(ReceiveMethod.InvitationFile, it) } }
if (actions.qrAvailability != ReceiveMethodAvailability.Hidden) ReceiveMethodRow(
ReceiveIcons.Scan, stringResource(Res.string.receive_method_scan), stringResource(Res.string.receive_method_scan_description),
actions.qrAvailability,
) { actions.scanQrCode { onResult(ReceiveMethod.QrCode, it) } }
if (actions.nfcAvailability != ReceiveMethodAvailability.Hidden) ReceiveMethodRow(
ReceiveIcons.Nfc,
if (isWaitingForNfc) stringResource(Res.string.receive_nfc_waiting) else stringResource(Res.string.receive_method_nfc),
stringResource(Res.string.receive_method_nfc_description),
if (isWaitingForNfc) ReceiveMethodAvailability.Unavailable else actions.nfcAvailability,
) {
onWaitingForNfc(true)
actions.readNfcInvitation { onResult(ReceiveMethod.Nfc, it) }
}
}
}
@Composable
private fun ReceiveMethodRow(icon: ImageVector, title: String, description: String, availability: ReceiveMethodAvailability, onClick: () -> Unit) {
val enabled = availability == ReceiveMethodAvailability.Available
Surface(
modifier = Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick),
shape = RoundedCornerShape(14.dp),
color = LocalVniDropColors.current.backgroundSurface200,
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, tint = if (enabled) LocalVniDropColors.current.brandLink else LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, fontWeight = FontWeight.SemiBold)
Text(description, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
if (availability == ReceiveMethodAvailability.Unavailable) Text(stringResource(Res.string.value_unavailable), color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean, onReceiverNameChanged: (String) -> Unit, onReceive: () -> Unit) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(stringResource(Res.string.receive_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (state.isInspecting) Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
state.inspection?.let { inspection ->
val metadata = inspection.metadata
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(metadata?.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (metadata != null) Text("${metadata.fileCount} ${stringResource(Res.string.metadata_files).lowercase()} · ${formatBytes(metadata.totalSize)}", color = LocalVniDropColors.current.foregroundLighter)
}
}
Field(state.receiverName, onReceiverNameChanged, stringResource(Res.string.field_receiver_name))
Text(
state.receiveFolder?.displayName ?: stringResource(Res.string.value_unavailable),
color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
)
PrimaryButton(
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
onClick = onReceive,
modifier = Modifier.fillMaxWidth(),
enabled = state.canReceive(coreInitialized),
)
}
}
}
@Composable
private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) {
Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) {
Icon(ReceiveIcons.File, null, tint = LocalVniDropColors.current.foregroundLighter)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(transfer.transferName ?: stringResource(Res.string.receive_unknown_transfer), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${formatBytes(transfer.totalSize)} · ${displayNameForStatus(transfer.status)}", color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
if (transfer.status.isTerminalReceiveHistory()) {
IconButton(onClick = onDelete) {
Icon(ReceiveIcons.Trash, stringResource(Res.string.receive_delete_history_item), tint = LocalVniDropColors.current.destructiveDefault)
}
}
}
}
}
@Composable
private fun ReceiveHistoryDeletePanel(
clearAll: Boolean,
transferName: String?,
isDeleting: Boolean,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
stringResource(if (clearAll) Res.string.receive_clear_history_title else Res.string.receive_delete_history_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(
if (clearAll) stringResource(Res.string.receive_clear_history_description)
else stringResource(Res.string.receive_delete_history_description, transferName ?: stringResource(Res.string.receive_unknown_transfer)),
color = LocalVniDropColors.current.foregroundLighter,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel, enabled = !isDeleting)
DestructiveButton(
if (isDeleting) stringResource(Res.string.transfer_deleting)
else stringResource(if (clearAll) Res.string.receive_clear_history else Res.string.button_delete_transfer),
onClick = onConfirm,
enabled = !isDeleting,
)
}
}
}
private object ReceiveIcons {
val Download = lineIcon("Download") { moveTo(12f, 3f); lineTo(12f, 15f); moveTo(7f, 10f); lineTo(12f, 15f); lineTo(17f, 10f); moveTo(4f, 20f); lineTo(20f, 20f) }
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 Scan = lineIcon("Scan") { moveTo(3f, 8f); lineTo(3f, 3f); lineTo(8f, 3f); moveTo(16f, 3f); lineTo(21f, 3f); lineTo(21f, 8f); moveTo(21f, 16f); lineTo(21f, 21f); lineTo(16f, 21f); moveTo(8f, 21f); lineTo(3f, 21f); lineTo(3f, 16f); moveTo(7f, 12f); lineTo(17f, 12f) }
val Nfc = lineIcon("Nfc") { moveTo(6f, 8f); curveTo(10f, 12f, 10f, 12f, 6f, 16f); moveTo(10f, 5f); curveTo(17f, 12f, 17f, 12f, 10f, 19f); moveTo(14f, 2f); curveTo(24f, 12f, 24f, 12f, 14f, 22f) }
val Trash = lineIcon("Delete") { moveTo(4f, 7f); lineTo(20f, 7f); moveTo(9f, 3f); lineTo(15f, 3f); lineTo(16f, 7f); moveTo(7f, 7f); lineTo(8f, 21f); lineTo(16f, 21f); lineTo(17f, 7f); moveTo(10f, 11f); lineTo(10f, 17f); moveTo(14f, 11f); lineTo(14f, 17f) }
}
private fun lineIcon(name: String, block: PathBuilder.() -> Unit) = ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(fill = SolidColor(Color.Transparent), stroke = SolidColor(Color.Black), strokeLineWidth = 2f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round, pathFillType = PathFillType.NonZero, pathBuilder = block)
}.build()

View File

@@ -7,26 +7,46 @@ import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.TicketInspectionModel
import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.receive_completed
import vnidrop.shared.generated.resources.receive_history_cleared
import vnidrop.shared.generated.resources.transfer_deleted
sealed interface ReceiveHistoryDeleteTarget {
data class Transfer(val transferId: ULong) : ReceiveHistoryDeleteTarget
data object All : ReceiveHistoryDeleteTarget
}
data class ReceiveState(
val isAcquisitionOpen: Boolean = false,
val ticket: String = "",
val outputDirectory: String = "",
val method: ReceiveMethod? = null,
val inspection: TicketInspectionModel? = null,
val receiverName: String = "",
val receiveFolder: ReceiveFolder? = null,
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
val isInspecting: Boolean = false,
val isReceiving: Boolean = false,
val isWaitingForNfc: Boolean = false,
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
val isDeletingHistory: Boolean = false,
) {
fun canInspect(coreInitialized: Boolean): Boolean = coreInitialized && ticket.isNotBlank()
fun canReceive(coreInitialized: Boolean): Boolean =
coreInitialized && ticket.isNotBlank() && outputDirectory.isNotBlank() &&
folderAccessStatus == FolderAccessStatus.Writable && !isReceiving
coreInitialized && ticket.isNotBlank() && inspection != null &&
folderAccessStatus == FolderAccessStatus.Writable && !isReceiving && !isInspecting
}
class ReceiveViewModel(
@@ -47,7 +67,6 @@ class ReceiveViewModel(
current.copy(
receiverName = current.receiverName.ifBlank { preferences.username },
receiveFolder = preferences.receiveFolder,
outputDirectory = preferences.receiveFolder.value,
folderAccessStatus = status,
)
}
@@ -55,14 +74,58 @@ class ReceiveViewModel(
}
}
fun setTicket(value: String) = _state.update { it.copy(ticket = value) }
fun setOutputDirectory(value: String) = _state.update { it.copy(outputDirectory = value) }
fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
fun dismissAcquisition() {
if (!_state.value.isReceiving && !_state.value.isInspecting) resetAcquisition()
}
fun setReceiverName(value: String) = _state.update { it.copy(receiverName = value) }
fun setWaitingForNfc(waiting: Boolean) = _state.update { it.copy(isWaitingForNfc = waiting) }
fun requestDeleteHistoryItem(transferId: ULong) {
val canDelete = coreState.value.transfers.any { transfer ->
transfer.transferId == transferId && transfer.direction == TransferDirection.Receive && transfer.status.isTerminalReceiveHistory()
}
if (canDelete) _state.update { it.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.Transfer(transferId)) }
}
fun requestClearHistory() {
if (coreState.value.transfers.any { it.direction == TransferDirection.Receive && it.status.isTerminalReceiveHistory() }) {
_state.update { it.copy(historyDeleteTarget = ReceiveHistoryDeleteTarget.All) }
}
}
fun dismissHistoryDelete() {
if (!_state.value.isDeletingHistory) _state.update { it.copy(historyDeleteTarget = null) }
}
fun confirmHistoryDelete() {
val target = _state.value.historyDeleteTarget ?: return
if (_state.value.isDeletingHistory) return
viewModelScope.launch {
_state.update { it.copy(isDeletingHistory = true) }
val result = when (target) {
is ReceiveHistoryDeleteTarget.Transfer -> repository.delete(target.transferId).map { Unit }
ReceiveHistoryDeleteTarget.All -> repository.clearReceiveHistory().map { Unit }
}
result.fold(
onSuccess = {
_state.update { it.copy(historyDeleteTarget = null, isDeletingHistory = false) }
val message = when (target) {
is ReceiveHistoryDeleteTarget.Transfer -> Res.string.transfer_deleted
ReceiveHistoryDeleteTarget.All -> Res.string.receive_history_cleared
}
messages.tryShow(UiMessage(UiText.Resource(message), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isDeletingHistory = false) }
messages.error(error)
},
)
}
}
fun inspectTicket() {
val current = state.value
if (!current.canInspect(coreState.value.isInitialized)) return
viewModelScope.launch { repository.inspectTicket(current.ticket).onFailure(messages::error) }
fun onInvitationResult(method: ReceiveMethod, result: Result<String>) {
_state.update { it.copy(isWaitingForNfc = false) }
result.fold(
onSuccess = { raw -> inspectInvitation(method, raw) },
onFailure = messages::error,
)
}
fun receive() {
@@ -71,21 +134,64 @@ class ReceiveViewModel(
if (!current.canReceive(coreState.value.isInitialized)) return
viewModelScope.launch {
_state.update { it.copy(isReceiving = true) }
try {
val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = when {
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
current.ticket,
folder.value,
current.receiverName,
)
else -> repository.receive(current.ticket, current.outputDirectory, current.receiverName)
}
result.onFailure(messages::error)
} finally {
_state.update { it.copy(isReceiving = false) }
val outputSink = fileSystemService.createReceiveOutputSink(folder)
val result = when {
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory(
current.ticket,
folder.value,
current.receiverName,
)
else -> repository.receive(current.ticket, folder.value, current.receiverName)
}
result.fold(
onSuccess = {
resetAcquisition()
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
},
onFailure = { error ->
_state.update { it.copy(isReceiving = false) }
messages.error(error)
},
)
}
}
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
val ticket = raw.trim()
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
viewModelScope.launch {
_state.update {
it.copy(
isAcquisitionOpen = true,
ticket = ticket,
method = method,
inspection = null,
isInspecting = true,
)
}
repository.inspectTicket(ticket).fold(
onSuccess = { inspection -> _state.update { it.copy(inspection = inspection, isInspecting = false) } },
onFailure = { error ->
_state.update { it.copy(ticket = "", method = null, inspection = null, isInspecting = false) }
messages.error(error)
},
)
}
}
private fun resetAcquisition() = _state.update {
it.copy(
isAcquisitionOpen = false,
ticket = "",
method = null,
inspection = null,
isInspecting = false,
isReceiving = false,
isWaitingForNfc = false,
)
}
}
internal fun TransferStatus.isTerminalReceiveHistory(): Boolean =
this == TransferStatus.Done || this == TransferStatus.Failed || this == TransferStatus.Cancelled

View File

@@ -41,6 +41,18 @@ fun QuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier
}
}
@Composable
fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
TextButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 40.dp),
colors = ButtonDefaults.textButtonColors(contentColor = LocalVniDropColors.current.destructiveDefault),
) {
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) {
Button(