mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
Merge pull request #6 from vnidrop/feat/transfer-progress
feat(ui): transfer progress, cancel, and delivery events
This commit is contained in:
8
.github/workflows/rust-core.yml
vendored
8
.github/workflows/rust-core.yml
vendored
@@ -8,6 +8,10 @@ on:
|
|||||||
- "crates/vnidrop/**"
|
- "crates/vnidrop/**"
|
||||||
- ".github/workflows/rust-core.yml"
|
- ".github/workflows/rust-core.yml"
|
||||||
push:
|
push:
|
||||||
|
# Only after merge (or direct master pushes). Feature-branch work is covered
|
||||||
|
# by pull_request so we do not run the same job twice on every PR update.
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
paths:
|
paths:
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
@@ -17,6 +21,10 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: rust-core-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
4
.github/workflows/shared-kmp.yml
vendored
4
.github/workflows/shared-kmp.yml
vendored
@@ -37,6 +37,10 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: shared-kmp-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
jvm-test:
|
jvm-test:
|
||||||
# Host Rust embedding is enabled only for the current Gobley host target.
|
# Host Rust embedding is enabled only for the current Gobley host target.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ bytes through Kotlin memory.
|
|||||||
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
|
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
|
||||||
`stopped`.
|
`stopped`.
|
||||||
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
|
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
|
||||||
`access`, `transfer`, `download`, `export`, `lifecycle`, `error`.
|
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`.
|
||||||
- Events are sent to `CoreEventSink` immediately and persisted through the event
|
- Events are sent to `CoreEventSink` immediately and persisted through the event
|
||||||
hub. `list_events` flushes queued persistence before reading SQLite.
|
hub. `list_events` flushes queued persistence before reading SQLite.
|
||||||
- `shutdown()` is idempotent and flushes events before stopping the router.
|
- `shutdown()` is idempotent and flushes events before stopping the router.
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ enum EventPhase {
|
|||||||
Handshake,
|
Handshake,
|
||||||
Approval,
|
Approval,
|
||||||
Transfer,
|
Transfer,
|
||||||
|
/// Delivery receipts from receivers (completed download acknowledgements).
|
||||||
|
Delivery,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPhase {
|
impl EventPhase {
|
||||||
@@ -65,6 +67,7 @@ impl EventPhase {
|
|||||||
"handshake" => Some(Self::Handshake),
|
"handshake" => Some(Self::Handshake),
|
||||||
"approval" => Some(Self::Approval),
|
"approval" => Some(Self::Approval),
|
||||||
"transfer" => Some(Self::Transfer),
|
"transfer" => Some(Self::Transfer),
|
||||||
|
"delivery" => Some(Self::Delivery),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +89,7 @@ impl EventPhase {
|
|||||||
Self::Handshake => "handshake",
|
Self::Handshake => "handshake",
|
||||||
Self::Approval => "approval",
|
Self::Approval => "approval",
|
||||||
Self::Transfer => "transfer",
|
Self::Transfer => "transfer",
|
||||||
|
Self::Delivery => "delivery",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ fn public_share_receives_without_sender_approval() {
|
|||||||
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
||||||
assert_eq!(deliveries[0].status, "completed");
|
assert_eq!(deliveries[0].status, "completed");
|
||||||
assert!(deliveries[0].completed_at.is_some());
|
assert!(deliveries[0].completed_at.is_some());
|
||||||
|
assert!(
|
||||||
|
sender.sink.events().iter().any(|event| {
|
||||||
|
event.phase == "delivery"
|
||||||
|
&& event.kind == "receiver-completed"
|
||||||
|
&& event.transfer_id == Some(share.transfer_id)
|
||||||
|
}),
|
||||||
|
"delivery receipts must emit a delivery phase event for UI live updates"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -111,6 +111,16 @@
|
|||||||
<string name="button_inspect_ticket">Inspect ticket</string>
|
<string name="button_inspect_ticket">Inspect ticket</string>
|
||||||
<string name="button_receive">Receive</string>
|
<string name="button_receive">Receive</string>
|
||||||
<string name="button_receiving">Receiving...</string>
|
<string name="button_receiving">Receiving...</string>
|
||||||
|
<string name="button_retry">Retry</string>
|
||||||
|
<string name="button_stop_sharing">Stop sharing</string>
|
||||||
|
<string name="button_cancel_receive">Cancel receive</string>
|
||||||
|
<string name="progress_receiving">Receiving files</string>
|
||||||
|
<string name="progress_preparing">Preparing transfer</string>
|
||||||
|
<string name="transfer_receivers_pending">%1$d waiting</string>
|
||||||
|
<string name="transfer_receivers_completed_count">%1$d completed</string>
|
||||||
|
<string name="transfer_event_downloading">Downloading files</string>
|
||||||
|
<string name="transfer_event_saving">Saving files</string>
|
||||||
|
<string name="transfer_event_connecting">Connecting to sender</string>
|
||||||
<string name="ticket_details_title">Ticket details</string>
|
<string name="ticket_details_title">Ticket details</string>
|
||||||
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
|
<string name="ticket_no_metadata">This ticket does not include VniDrop metadata.</string>
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ data class CoreState(
|
|||||||
sealed interface CoreSignal {
|
sealed interface CoreSignal {
|
||||||
data class ApprovalChanged(val transferId: ULong) : CoreSignal
|
data class ApprovalChanged(val transferId: ULong) : CoreSignal
|
||||||
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
|
data class ReceiverHistoryChanged(val transferId: ULong) : CoreSignal
|
||||||
|
/** Transfer status/history changed enough to re-read the durable snapshot. */
|
||||||
|
data class TransfersChanged(val transferId: ULong) : CoreSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CoreGateway {
|
interface CoreGateway {
|
||||||
|
|||||||
@@ -46,11 +46,15 @@ class CoreRepository(
|
|||||||
override fun onEvent(event: CoreEvent) {
|
override fun onEvent(event: CoreEvent) {
|
||||||
val model = event.toModel()
|
val model = event.toModel()
|
||||||
_state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) }
|
_state.update { current -> current.copy(events = (listOf(model) + current.events).take(MaxEvents)) }
|
||||||
if (model.phase == "approval" && model.transferId != null) {
|
val transferId = model.transferId
|
||||||
_signals.tryEmit(CoreSignal.ApprovalChanged(model.transferId))
|
if (transferId != null) {
|
||||||
|
when (model.phase) {
|
||||||
|
"approval" -> _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||||
|
"delivery" -> _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||||
|
}
|
||||||
|
if (model.shouldRefreshTransfers()) {
|
||||||
|
_signals.tryEmit(CoreSignal.TransfersChanged(transferId))
|
||||||
}
|
}
|
||||||
if (model.phase == "delivery" && model.transferId != null) {
|
|
||||||
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(model.transferId))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,6 +273,13 @@ private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
|
|||||||
dataJson = dataJson,
|
dataJson = dataJson,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun CoreEventModel.shouldRefreshTransfers(): Boolean =
|
||||||
|
phase in setOf("lifecycle", "error", "ticket", "import", "download", "export", "handshake") &&
|
||||||
|
kind in setOf(
|
||||||
|
"started", "done", "created", "failed", "cancelled", "share-stopped",
|
||||||
|
"found-collection", "connected",
|
||||||
|
)
|
||||||
|
|
||||||
private fun StoredTransfer.toModel(): Transfer = Transfer(
|
private fun StoredTransfer.toModel(): Transfer = Transfer(
|
||||||
localId = localId,
|
localId = localId,
|
||||||
transferId = transferId,
|
transferId = transferId,
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ class ApprovalCoordinator(
|
|||||||
repository.signals.collect { signal ->
|
repository.signals.collect { signal ->
|
||||||
when (signal) {
|
when (signal) {
|
||||||
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
|
is CoreSignal.ApprovalChanged -> refresh(signal.transferId)
|
||||||
is CoreSignal.ReceiverHistoryChanged -> Unit
|
is CoreSignal.ReceiverHistoryChanged,
|
||||||
|
is CoreSignal.TransfersChanged -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ fun ReceiveRoute(viewModel: ReceiveViewModel, windowClass: WindowClass) {
|
|||||||
onInvitationResult = viewModel::onInvitationResult,
|
onInvitationResult = viewModel::onInvitationResult,
|
||||||
onWaitingForNfc = viewModel::setWaitingForNfc,
|
onWaitingForNfc = viewModel::setWaitingForNfc,
|
||||||
onReceive = viewModel::receive,
|
onReceive = viewModel::receive,
|
||||||
|
onCancelReceive = viewModel::cancelActiveReceive,
|
||||||
onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem,
|
onRequestDeleteHistoryItem = viewModel::requestDeleteHistoryItem,
|
||||||
onRequestClearHistory = viewModel::requestClearHistory,
|
onRequestClearHistory = viewModel::requestClearHistory,
|
||||||
onDismissHistoryDelete = viewModel::dismissHistoryDelete,
|
onDismissHistoryDelete = viewModel::dismissHistoryDelete,
|
||||||
|
|||||||
@@ -45,15 +45,18 @@ import com.vnidrop.app.core.CoreState
|
|||||||
import com.vnidrop.app.core.FolderAccessStatus
|
import com.vnidrop.app.core.FolderAccessStatus
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
import com.vnidrop.app.core.TransferDirection
|
import com.vnidrop.app.core.TransferDirection
|
||||||
|
import com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||||
import com.vnidrop.app.ui.components.DestructiveButton
|
import com.vnidrop.app.ui.components.DestructiveButton
|
||||||
import com.vnidrop.app.ui.components.DestructiveQuietButton
|
import com.vnidrop.app.ui.components.DestructiveQuietButton
|
||||||
import com.vnidrop.app.ui.components.Field
|
import com.vnidrop.app.ui.components.Field
|
||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.ProgressRow
|
||||||
import com.vnidrop.app.ui.components.SecondaryButton
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
|
import com.vnidrop.app.ui.state.progressForTransfer
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import vnidrop.shared.generated.resources.*
|
import vnidrop.shared.generated.resources.*
|
||||||
@@ -80,6 +83,7 @@ fun ReceiveScreen(
|
|||||||
onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
|
onInvitationResult: (ReceiveMethod, Result<String>) -> Unit,
|
||||||
onWaitingForNfc: (Boolean) -> Unit,
|
onWaitingForNfc: (Boolean) -> Unit,
|
||||||
onReceive: () -> Unit,
|
onReceive: () -> Unit,
|
||||||
|
onCancelReceive: () -> Unit = {},
|
||||||
onRequestDeleteHistoryItem: (ULong) -> Unit,
|
onRequestDeleteHistoryItem: (ULong) -> Unit,
|
||||||
onRequestClearHistory: () -> Unit,
|
onRequestClearHistory: () -> Unit,
|
||||||
onDismissHistoryDelete: () -> Unit,
|
onDismissHistoryDelete: () -> Unit,
|
||||||
@@ -102,7 +106,11 @@ fun ReceiveScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(transfers, key = Transfer::localId) { transfer ->
|
items(transfers, key = Transfer::localId) { transfer ->
|
||||||
ReceiveTransferRow(transfer, onDelete = { onRequestDeleteHistoryItem(transfer.transferId) })
|
ReceiveTransferRow(
|
||||||
|
transfer = transfer,
|
||||||
|
progress = progressForTransfer(coreState.events, transfer.transferId),
|
||||||
|
onDelete = { onRequestDeleteHistoryItem(transfer.transferId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,8 +128,10 @@ fun ReceiveScreen(
|
|||||||
InvitationReviewPanel(
|
InvitationReviewPanel(
|
||||||
state = state,
|
state = state,
|
||||||
coreInitialized = coreState.isInitialized,
|
coreInitialized = coreState.isInitialized,
|
||||||
|
events = coreState.events,
|
||||||
onReceiverNameChanged = onReceiverNameChanged,
|
onReceiverNameChanged = onReceiverNameChanged,
|
||||||
onReceive = onReceive,
|
onReceive = onReceive,
|
||||||
|
onCancelReceive = onCancelReceive,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +240,14 @@ private fun ReceiveMethodRow(icon: ImageVector, title: String, description: Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean, onReceiverNameChanged: (String) -> Unit, onReceive: () -> Unit) {
|
private fun InvitationReviewPanel(
|
||||||
|
state: ReceiveState,
|
||||||
|
coreInitialized: Boolean,
|
||||||
|
events: List<com.vnidrop.app.core.CoreEventModel>,
|
||||||
|
onReceiverNameChanged: (String) -> Unit,
|
||||||
|
onReceive: () -> Unit,
|
||||||
|
onCancelReceive: () -> Unit,
|
||||||
|
) {
|
||||||
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
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)
|
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() }
|
if (state.isInspecting) Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
@@ -248,18 +265,41 @@ private fun InvitationReviewPanel(state: ReceiveState, coreInitialized: Boolean,
|
|||||||
color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault,
|
color = if (state.folderAccessStatus == FolderAccessStatus.Writable) LocalVniDropColors.current.foregroundLight else LocalVniDropColors.current.destructiveDefault,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
if (state.isReceiving) {
|
||||||
|
val progressId = state.activeReceiveTransferId
|
||||||
|
?: events.firstOrNull { it.direction == "receive" && it.transferId != null }?.transferId
|
||||||
|
val progress = progressId?.let { progressForTransfer(events, it) }
|
||||||
|
ProgressRow(
|
||||||
|
label = progress?.label ?: stringResource(Res.string.progress_receiving),
|
||||||
|
progress = progress?.progress,
|
||||||
|
detail = progress?.detail,
|
||||||
|
)
|
||||||
|
SecondaryButton(
|
||||||
|
stringResource(Res.string.button_cancel_receive),
|
||||||
|
onClick = onCancelReceive,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
if (state.isReceiving) stringResource(Res.string.button_receiving) else stringResource(Res.string.button_receive),
|
stringResource(Res.string.button_receive),
|
||||||
onClick = onReceive,
|
onClick = onReceive,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = state.canReceive(coreInitialized),
|
enabled = state.canReceive(coreInitialized),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
state.lastReceiveError?.let { error ->
|
||||||
|
Text(error, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) {
|
private fun ReceiveTransferRow(
|
||||||
|
transfer: Transfer,
|
||||||
|
progress: com.vnidrop.app.ui.state.TransferProgress?,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
Surface(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
|
||||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(10.dp)), contentAlignment = Alignment.Center) {
|
||||||
@@ -269,6 +309,9 @@ private fun ReceiveTransferRow(transfer: Transfer, onDelete: () -> Unit) {
|
|||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.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(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)
|
Text("${formatBytes(transfer.totalSize)} · ${displayNameForStatus(transfer.status)}", color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
|
||||||
|
if (transfer.status == TransferStatus.Receiving && progress != null) {
|
||||||
|
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (transfer.status.isTerminalReceiveHistory()) {
|
if (transfer.status.isTerminalReceiveHistory()) {
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.vnidrop.app.feature.receive
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vnidrop.app.core.CoreGateway
|
import com.vnidrop.app.core.CoreGateway
|
||||||
|
import com.vnidrop.app.core.CoreSignal
|
||||||
import com.vnidrop.app.core.FileSystemService
|
import com.vnidrop.app.core.FileSystemService
|
||||||
import com.vnidrop.app.core.FolderAccessStatus
|
import com.vnidrop.app.core.FolderAccessStatus
|
||||||
import com.vnidrop.app.core.ReceiveFolder
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
@@ -21,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
|
import vnidrop.shared.generated.resources.button_retry
|
||||||
import vnidrop.shared.generated.resources.receive_completed
|
import vnidrop.shared.generated.resources.receive_completed
|
||||||
import vnidrop.shared.generated.resources.receive_history_cleared
|
import vnidrop.shared.generated.resources.receive_history_cleared
|
||||||
import vnidrop.shared.generated.resources.transfer_deleted
|
import vnidrop.shared.generated.resources.transfer_deleted
|
||||||
@@ -40,6 +42,8 @@ data class ReceiveState(
|
|||||||
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
|
val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable,
|
||||||
val isInspecting: Boolean = false,
|
val isInspecting: Boolean = false,
|
||||||
val isReceiving: Boolean = false,
|
val isReceiving: Boolean = false,
|
||||||
|
val activeReceiveTransferId: ULong? = null,
|
||||||
|
val lastReceiveError: String? = null,
|
||||||
val isWaitingForNfc: Boolean = false,
|
val isWaitingForNfc: Boolean = false,
|
||||||
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
|
val historyDeleteTarget: ReceiveHistoryDeleteTarget? = null,
|
||||||
val isDeletingHistory: Boolean = false,
|
val isDeletingHistory: Boolean = false,
|
||||||
@@ -72,6 +76,20 @@ class ReceiveViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.signals.collect { signal ->
|
||||||
|
when (signal) {
|
||||||
|
is CoreSignal.TransfersChanged -> {
|
||||||
|
repository.refresh()
|
||||||
|
if (_state.value.isReceiving && signal.transferId != 0UL) {
|
||||||
|
_state.update { it.copy(activeReceiveTransferId = signal.transferId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CoreSignal.ApprovalChanged,
|
||||||
|
is CoreSignal.ReceiverHistoryChanged -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
|
fun openAcquisition() = _state.update { it.copy(isAcquisitionOpen = true) }
|
||||||
@@ -133,7 +151,9 @@ class ReceiveViewModel(
|
|||||||
val folder = current.receiveFolder ?: return
|
val folder = current.receiveFolder ?: return
|
||||||
if (!current.canReceive(coreState.value.isInitialized)) return
|
if (!current.canReceive(coreState.value.isInitialized)) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isReceiving = true) }
|
_state.update {
|
||||||
|
it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null)
|
||||||
|
}
|
||||||
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
val outputSink = fileSystemService.createReceiveOutputSink(folder)
|
||||||
val result = when {
|
val result = when {
|
||||||
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName)
|
||||||
@@ -150,13 +170,49 @@ class ReceiveViewModel(
|
|||||||
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
|
messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success))
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
_state.update { it.copy(isReceiving = false) }
|
val message = error.message?.takeIf(String::isNotBlank) ?: "Something went wrong."
|
||||||
messages.error(error)
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isReceiving = false,
|
||||||
|
activeReceiveTransferId = null,
|
||||||
|
lastReceiveError = message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
messages.tryShow(
|
||||||
|
UiMessage(
|
||||||
|
text = UiText.Dynamic(message),
|
||||||
|
tone = UiMessageTone.Error,
|
||||||
|
actionLabel = UiText.Resource(Res.string.button_retry),
|
||||||
|
onAction = { receive() },
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelActiveReceive() {
|
||||||
|
val transferId = _state.value.activeReceiveTransferId
|
||||||
|
?: coreState.value.transfers.firstOrNull {
|
||||||
|
it.direction == TransferDirection.Receive && it.status == TransferStatus.Receiving
|
||||||
|
}?.transferId
|
||||||
|
?: coreState.value.events.firstOrNull {
|
||||||
|
it.direction == "receive" && it.transferId != null
|
||||||
|
}?.transferId
|
||||||
|
?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.cancel(transferId).fold(
|
||||||
|
onSuccess = {
|
||||||
|
_state.update {
|
||||||
|
it.copy(isReceiving = false, activeReceiveTransferId = null, lastReceiveError = null)
|
||||||
|
}
|
||||||
|
repository.refresh()
|
||||||
|
},
|
||||||
|
onFailure = messages::error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
|
private fun inspectInvitation(method: ReceiveMethod, raw: String) {
|
||||||
val ticket = raw.trim()
|
val ticket = raw.trim()
|
||||||
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
|
if (ticket.isBlank()) return messages.error(IllegalArgumentException("The invitation is empty"))
|
||||||
@@ -188,6 +244,8 @@ class ReceiveViewModel(
|
|||||||
inspection = null,
|
inspection = null,
|
||||||
isInspecting = false,
|
isInspecting = false,
|
||||||
isReceiving = false,
|
isReceiving = false,
|
||||||
|
activeReceiveTransferId = null,
|
||||||
|
lastReceiveError = null,
|
||||||
isWaitingForNfc = false,
|
isWaitingForNfc = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
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 com.vnidrop.app.core.TransferStatus
|
||||||
import com.vnidrop.app.ui.components.PillTone
|
import com.vnidrop.app.ui.components.PillTone
|
||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.ProgressRow
|
||||||
import com.vnidrop.app.ui.components.StatusPill
|
import com.vnidrop.app.ui.components.StatusPill
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
|
import com.vnidrop.app.ui.state.progressForTransfer
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||||
@@ -71,6 +74,7 @@ internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifi
|
|||||||
internal fun TransferCatalog(
|
internal fun TransferCatalog(
|
||||||
transfers: List<Transfer>,
|
transfers: List<Transfer>,
|
||||||
transferThumbnails: Map<ULong, ByteArray>,
|
transferThumbnails: Map<ULong, ByteArray>,
|
||||||
|
events: List<CoreEventModel> = emptyList(),
|
||||||
windowClass: WindowClass,
|
windowClass: WindowClass,
|
||||||
onOpenComposer: () -> Unit,
|
onOpenComposer: () -> Unit,
|
||||||
onTransferSelected: (ULong) -> Unit,
|
onTransferSelected: (ULong) -> Unit,
|
||||||
@@ -97,7 +101,12 @@ internal fun TransferCatalog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(transfers, key = Transfer::localId) { transfer ->
|
items(transfers, key = Transfer::localId) { transfer ->
|
||||||
TransferListItem(transfer, transferThumbnails[transfer.transferId]) { onTransferSelected(transfer.transferId) }
|
TransferListItem(
|
||||||
|
transfer = transfer,
|
||||||
|
thumbnailBytes = transferThumbnails[transfer.transferId],
|
||||||
|
progress = progressForTransfer(events, transfer.transferId),
|
||||||
|
onClick = { onTransferSelected(transfer.transferId) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +167,12 @@ private fun SendEmptyState(onOpenComposer: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onClick: () -> Unit) {
|
private fun TransferListItem(
|
||||||
|
transfer: Transfer,
|
||||||
|
thumbnailBytes: ByteArray?,
|
||||||
|
progress: com.vnidrop.app.ui.state.TransferProgress?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
val colors = LocalVniDropColors.current
|
val colors = LocalVniDropColors.current
|
||||||
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
|
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -169,7 +183,7 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
|
|||||||
FileArtwork(thumbnailBytes, Modifier.fillMaxSize())
|
FileArtwork(thumbnailBytes, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
|
transfer.transferName ?: stringResource(Res.string.send_new_transfer_title),
|
||||||
@@ -189,6 +203,9 @@ private fun TransferListItem(transfer: Transfer, thumbnailBytes: ByteArray?, onC
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
if (transfer.status == TransferStatus.Importing && progress != null) {
|
||||||
|
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
|
Icon(SendIcons.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||||
|
|||||||
@@ -53,5 +53,6 @@ fun SendRoute(
|
|||||||
onRequestDelete = viewModel::requestDeleteTransfer,
|
onRequestDelete = viewModel::requestDeleteTransfer,
|
||||||
onDismissDelete = viewModel::dismissDeleteTransfer,
|
onDismissDelete = viewModel::dismissDeleteTransfer,
|
||||||
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
||||||
|
onCancelTransfer = viewModel::cancelSelectedTransfer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import com.vnidrop.app.core.CoreState
|
import com.vnidrop.app.core.CoreState
|
||||||
|
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.TransferDirection
|
import com.vnidrop.app.core.TransferDirection
|
||||||
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||||
import com.vnidrop.app.ui.state.WindowClass
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
|
import com.vnidrop.app.ui.state.canCancelTransfer
|
||||||
|
import com.vnidrop.app.ui.state.progressForTransfer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendScreen(
|
fun SendScreen(
|
||||||
@@ -39,6 +42,7 @@ fun SendScreen(
|
|||||||
onRequestDelete: () -> Unit = {},
|
onRequestDelete: () -> Unit = {},
|
||||||
onDismissDelete: () -> Unit = {},
|
onDismissDelete: () -> Unit = {},
|
||||||
onConfirmDelete: () -> Unit = {},
|
onConfirmDelete: () -> Unit = {},
|
||||||
|
onCancelTransfer: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||||
@@ -52,17 +56,24 @@ fun SendScreen(
|
|||||||
TransferDetails(
|
TransferDetails(
|
||||||
transfer = selectedTransfer,
|
transfer = selectedTransfer,
|
||||||
events = coreState.events,
|
events = coreState.events,
|
||||||
completedReceivers = state.receiverHistory.count { it.status == com.vnidrop.app.core.ReceiverDeliveryStatus.Completed },
|
progress = progressForTransfer(coreState.events, selectedTransfer.transferId),
|
||||||
|
pendingReceivers = state.receiverHistory.count {
|
||||||
|
it.status == ReceiverDeliveryStatus.Requested || it.status == ReceiverDeliveryStatus.Accepted
|
||||||
|
},
|
||||||
|
completedReceivers = state.receiverHistory.count { it.status == ReceiverDeliveryStatus.Completed },
|
||||||
|
canCancel = selectedTransfer.canCancelTransfer(),
|
||||||
onBack = onCloseTransferDetails,
|
onBack = onCloseTransferDetails,
|
||||||
onActivity = onActivity,
|
onActivity = onActivity,
|
||||||
onReceivers = onReceivers,
|
onReceivers = onReceivers,
|
||||||
onShare = onShare,
|
onShare = onShare,
|
||||||
onDelete = onRequestDelete,
|
onDelete = onRequestDelete,
|
||||||
|
onCancel = onCancelTransfer,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
TransferCatalog(
|
TransferCatalog(
|
||||||
transfers = outgoingTransfers,
|
transfers = outgoingTransfers,
|
||||||
transferThumbnails = state.transferThumbnails,
|
transferThumbnails = state.transferThumbnails,
|
||||||
|
events = coreState.events,
|
||||||
windowClass = windowClass,
|
windowClass = windowClass,
|
||||||
onOpenComposer = onOpenComposer,
|
onOpenComposer = onOpenComposer,
|
||||||
onTransferSelected = onTransferSelected,
|
onTransferSelected = onTransferSelected,
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
import vnidrop.shared.generated.resources.send_transfer_created
|
import vnidrop.shared.generated.resources.send_transfer_created
|
||||||
import vnidrop.shared.generated.resources.transfer_nfc_written
|
|
||||||
import vnidrop.shared.generated.resources.transfer_deleted
|
import vnidrop.shared.generated.resources.transfer_deleted
|
||||||
|
import vnidrop.shared.generated.resources.transfer_event_stopped
|
||||||
|
import vnidrop.shared.generated.resources.transfer_nfc_written
|
||||||
|
|
||||||
data class SendState(
|
data class SendState(
|
||||||
val isComposerOpen: Boolean = false,
|
val isComposerOpen: Boolean = false,
|
||||||
@@ -70,13 +71,19 @@ class SendViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.signals.collect { signal ->
|
repository.signals.collect { signal ->
|
||||||
val transferId = when (signal) {
|
when (signal) {
|
||||||
is CoreSignal.ReceiverHistoryChanged -> signal.transferId
|
is CoreSignal.TransfersChanged -> repository.refresh()
|
||||||
is CoreSignal.ApprovalChanged -> signal.transferId
|
is CoreSignal.ReceiverHistoryChanged -> {
|
||||||
|
if (signal.transferId == _state.value.selectedTransferId) {
|
||||||
|
refreshReceivers(signal.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CoreSignal.ApprovalChanged -> {
|
||||||
|
if (signal.transferId == _state.value.selectedTransferId) {
|
||||||
|
refreshReceivers(signal.transferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (transferId == _state.value.selectedTransferId &&
|
|
||||||
_state.value.detailPanel == TransferDetailPanel.Receivers
|
|
||||||
) refreshReceivers(transferId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -245,6 +252,21 @@ class SendViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun cancelSelectedTransfer() {
|
||||||
|
val transferId = _state.value.selectedTransferId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.cancel(transferId).fold(
|
||||||
|
onSuccess = {
|
||||||
|
repository.refresh()
|
||||||
|
messages.tryShow(
|
||||||
|
UiMessage(UiText.Resource(Res.string.transfer_event_stopped), UiMessageTone.Info),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = messages::error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun sendEffect(effect: SendEffect) {
|
private fun sendEffect(effect: SendEffect) {
|
||||||
viewModelScope.launch { effects.send(effect) }
|
viewModelScope.launch { effects.send(effect) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ import com.vnidrop.app.core.Transfer
|
|||||||
import com.vnidrop.app.ui.components.AppCard
|
import com.vnidrop.app.ui.components.AppCard
|
||||||
import com.vnidrop.app.ui.components.DestructiveButton
|
import com.vnidrop.app.ui.components.DestructiveButton
|
||||||
import com.vnidrop.app.ui.components.PrimaryButton
|
import com.vnidrop.app.ui.components.PrimaryButton
|
||||||
|
import com.vnidrop.app.ui.components.ProgressRow
|
||||||
import com.vnidrop.app.ui.components.SecondaryButton
|
import com.vnidrop.app.ui.components.SecondaryButton
|
||||||
|
import com.vnidrop.app.ui.state.TransferProgress
|
||||||
import com.vnidrop.app.ui.state.displayNameForStatus
|
import com.vnidrop.app.ui.state.displayNameForStatus
|
||||||
import com.vnidrop.app.ui.state.formatBytes
|
import com.vnidrop.app.ui.state.formatBytes
|
||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
@@ -67,12 +69,16 @@ enum class InvitationAction { Export, Share, Nfc }
|
|||||||
internal fun TransferDetails(
|
internal fun TransferDetails(
|
||||||
transfer: Transfer,
|
transfer: Transfer,
|
||||||
events: List<CoreEventModel>,
|
events: List<CoreEventModel>,
|
||||||
|
progress: TransferProgress? = null,
|
||||||
|
pendingReceivers: Int = 0,
|
||||||
completedReceivers: Int,
|
completedReceivers: Int,
|
||||||
|
canCancel: Boolean = false,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onActivity: () -> Unit,
|
onActivity: () -> Unit,
|
||||||
onReceivers: () -> Unit,
|
onReceivers: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onCancel: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||||
@@ -100,6 +106,24 @@ internal fun TransferDetails(
|
|||||||
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
|
DetailValue(stringResource(Res.string.metadata_size), formatBytes(transfer.totalSize))
|
||||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
|
DetailValue(stringResource(Res.string.send_access_title), accessPolicyLabel(transfer.accessPolicy))
|
||||||
|
if (progress != null && transfer.status.isLiveProgressStatus()) {
|
||||||
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
|
ProgressRow(
|
||||||
|
label = progress.label,
|
||||||
|
progress = progress.progress,
|
||||||
|
detail = progress.detail,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canCancel) {
|
||||||
|
item {
|
||||||
|
SecondaryButton(
|
||||||
|
stringResource(Res.string.button_stop_sharing),
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -114,8 +138,8 @@ internal fun TransferDetails(
|
|||||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
DetailDestination(
|
DetailDestination(
|
||||||
title = stringResource(Res.string.transfer_receivers_title),
|
title = stringResource(Res.string.transfer_receivers_title),
|
||||||
description = stringResource(Res.string.transfer_receivers_description),
|
description = receiversDescription(pendingReceivers, completedReceivers),
|
||||||
count = completedReceivers,
|
count = pendingReceivers + completedReceivers,
|
||||||
onClick = onReceivers,
|
onClick = onReceivers,
|
||||||
)
|
)
|
||||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||||
@@ -130,6 +154,20 @@ internal fun TransferDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun receiversDescription(pending: Int, completed: Int): String = when {
|
||||||
|
pending > 0 && completed > 0 ->
|
||||||
|
"${stringResource(Res.string.transfer_receivers_pending, pending)} · ${stringResource(Res.string.transfer_receivers_completed_count, completed)}"
|
||||||
|
pending > 0 -> stringResource(Res.string.transfer_receivers_pending, pending)
|
||||||
|
completed > 0 -> stringResource(Res.string.transfer_receivers_completed_count, completed)
|
||||||
|
else -> stringResource(Res.string.transfer_receivers_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun com.vnidrop.app.core.TransferStatus.isLiveProgressStatus(): Boolean =
|
||||||
|
this == com.vnidrop.app.core.TransferStatus.Importing ||
|
||||||
|
this == com.vnidrop.app.core.TransferStatus.Sharing ||
|
||||||
|
this == com.vnidrop.app.core.TransferStatus.Receiving
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
|
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -314,6 +352,9 @@ private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status)
|
|||||||
private fun CoreEventModel.isMeaningfulActivity() =
|
private fun CoreEventModel.isMeaningfulActivity() =
|
||||||
(phase == "import" && kind == "started") ||
|
(phase == "import" && kind == "started") ||
|
||||||
(phase == "ticket" && kind == "created") ||
|
(phase == "ticket" && kind == "created") ||
|
||||||
|
(phase == "network" && kind in setOf("connecting", "connected")) ||
|
||||||
|
(phase == "download" && kind == "found-collection") ||
|
||||||
|
(phase == "lifecycle" && kind in setOf("done", "cancelled", "share-stopped")) ||
|
||||||
kind in setOf(
|
kind in setOf(
|
||||||
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
|
"receiver-requested", "receiver-accepted", "receiver-auto-approved",
|
||||||
"receiver-refused", "receiver-completed", "share-stopped", "failed",
|
"receiver-refused", "receiver-completed", "share-stopped", "failed",
|
||||||
@@ -323,11 +364,15 @@ private fun CoreEventModel.isMeaningfulActivity() =
|
|||||||
private fun eventTitle(event: CoreEventModel) = stringResource(when {
|
private fun eventTitle(event: CoreEventModel) = stringResource(when {
|
||||||
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
|
event.phase == "import" && event.kind == "started" -> Res.string.transfer_event_preparing
|
||||||
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
|
event.phase == "ticket" && event.kind == "created" -> Res.string.transfer_event_ready
|
||||||
|
event.phase == "network" -> Res.string.transfer_event_connecting
|
||||||
|
event.phase == "download" -> Res.string.transfer_event_downloading
|
||||||
|
event.phase == "export" -> Res.string.transfer_event_saving
|
||||||
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
|
event.kind == "receiver-requested" -> Res.string.transfer_event_requested
|
||||||
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
|
event.kind == "receiver-accepted" || event.kind == "receiver-auto-approved" -> Res.string.transfer_event_approved
|
||||||
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
|
event.kind == "receiver-refused" -> Res.string.transfer_event_refused
|
||||||
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
|
event.kind == "receiver-completed" -> Res.string.transfer_event_completed
|
||||||
event.kind == "share-stopped" -> Res.string.transfer_event_stopped
|
event.kind == "share-stopped" || (event.phase == "lifecycle" && event.kind == "cancelled") ->
|
||||||
|
Res.string.transfer_event_stopped
|
||||||
event.kind == "failed" -> Res.string.transfer_event_failed
|
event.kind == "failed" -> Res.string.transfer_event_failed
|
||||||
else -> Res.string.transfer_event_updated
|
else -> Res.string.transfer_event_updated
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,9 +15,42 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProgressRow(label: String, progress: Float?, modifier: Modifier = Modifier) {
|
fun ProgressRow(
|
||||||
|
label: String,
|
||||||
|
progress: Float?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
detail: String? = null,
|
||||||
|
) {
|
||||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
if (progress != null) {
|
||||||
|
Text(
|
||||||
|
"${(progress * 100).toInt()}%",
|
||||||
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (detail != null) {
|
||||||
|
Text(
|
||||||
|
detail,
|
||||||
|
color = LocalVniDropColors.current.foregroundLighter,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (progress == null) LinearProgressIndicator(Modifier.fillMaxWidth())
|
if (progress == null) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||||
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
else LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ fun useBottomNavigation(windowClass: WindowClass): Boolean =
|
|||||||
data class TransferProgress(
|
data class TransferProgress(
|
||||||
val transferId: ULong?,
|
val transferId: ULong?,
|
||||||
val phase: String,
|
val phase: String,
|
||||||
|
val kind: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val progress: Float?,
|
val progress: Float?,
|
||||||
|
val detail: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun displayNameForStatus(status: TransferStatus): String =
|
fun displayNameForStatus(status: TransferStatus): String =
|
||||||
@@ -42,19 +44,36 @@ fun displayNameForStatus(status: TransferStatus): String =
|
|||||||
fun Transfer.isActiveTransfer(): Boolean =
|
fun Transfer.isActiveTransfer(): Boolean =
|
||||||
status in activeTransferStatuses
|
status in activeTransferStatuses
|
||||||
|
|
||||||
|
fun Transfer.canCancelTransfer(): Boolean =
|
||||||
|
status in setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest progress snapshot for a transfer, derived from core events.
|
||||||
|
*
|
||||||
|
* Events are assumed newest-first (as stored by [com.vnidrop.app.core.CoreRepository]).
|
||||||
|
*/
|
||||||
|
fun progressForTransfer(events: List<CoreEventModel>, transferId: ULong): TransferProgress? {
|
||||||
|
val relevant = events.filter { event ->
|
||||||
|
event.transferId == transferId && event.phase in progressPhases && event.kind in progressKinds
|
||||||
|
}
|
||||||
|
val latest = relevant.firstOrNull() ?: return null
|
||||||
|
val sizeHint = findKnownSize(events, transferId)
|
||||||
|
return TransferProgress(
|
||||||
|
transferId = transferId,
|
||||||
|
phase = latest.phase,
|
||||||
|
kind = latest.kind,
|
||||||
|
label = humanProgressLabel(latest),
|
||||||
|
progress = parseProgress(latest.dataJson, sizeHint),
|
||||||
|
detail = progressDetail(latest),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
fun summarizeProgress(events: List<CoreEventModel>): List<TransferProgress> =
|
||||||
events
|
events
|
||||||
.filter { event -> event.transferId != null && event.phase in progressPhases }
|
.mapNotNull { it.transferId }
|
||||||
.distinctBy { event -> "${event.transferId}:${event.phase}" }
|
.distinct()
|
||||||
.take(6)
|
.take(6)
|
||||||
.map { event ->
|
.mapNotNull { progressForTransfer(events, it) }
|
||||||
TransferProgress(
|
|
||||||
transferId = event.transferId,
|
|
||||||
phase = event.phase,
|
|
||||||
label = eventLabel(event),
|
|
||||||
progress = parseProgress(event.dataJson),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun transferSubtitle(transfer: Transfer): String {
|
fun transferSubtitle(transfer: Transfer): String {
|
||||||
val pieces = listOfNotNull(
|
val pieces = listOfNotNull(
|
||||||
@@ -81,28 +100,103 @@ fun formatBytes(size: ULong): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val progressPhases = setOf("import", "ticket", "access", "transfer", "download", "export", "lifecycle")
|
private val progressPhases = setOf(
|
||||||
private val activeTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
|
"import", "ticket", "access", "transfer", "download", "export",
|
||||||
|
"lifecycle", "network", "handshake", "error",
|
||||||
|
)
|
||||||
|
|
||||||
private fun eventLabel(event: CoreEventModel): String {
|
private val progressKinds = setOf(
|
||||||
val direction = event.direction?.replaceFirstChar { it.uppercase() }
|
"started", "copy-progress", "copy-done", "outboard-progress", "done",
|
||||||
val phase = event.phase.replaceFirstChar { it.uppercase() }
|
"created", "progress", "completed", "aborted", "failed",
|
||||||
val kind = event.kind.replace('-', ' ')
|
"connecting", "connected", "found-collection",
|
||||||
return listOfNotNull(direction, phase, kind).joinToString(" - ")
|
"cancelled", "share-stopped",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val activeTransferStatuses = setOf(
|
||||||
|
TransferStatus.Importing,
|
||||||
|
TransferStatus.Sharing,
|
||||||
|
TransferStatus.Receiving,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun humanProgressLabel(event: CoreEventModel): String = when {
|
||||||
|
event.phase == "import" && event.kind == "copy-progress" -> "Preparing files"
|
||||||
|
event.phase == "import" && event.kind == "outboard-progress" -> "Indexing files"
|
||||||
|
event.phase == "import" && event.kind == "started" -> "Preparing transfer"
|
||||||
|
event.phase == "import" && event.kind == "done" -> "Files ready"
|
||||||
|
event.phase == "ticket" && event.kind == "created" -> "Share ready"
|
||||||
|
event.phase == "network" && event.kind == "connecting" -> "Connecting to sender"
|
||||||
|
event.phase == "network" && event.kind == "connected" -> "Connected"
|
||||||
|
event.phase == "handshake" -> "Requesting access"
|
||||||
|
event.phase == "download" && event.kind == "found-collection" -> "Found files"
|
||||||
|
event.phase == "download" && event.kind == "progress" -> "Downloading"
|
||||||
|
event.phase == "export" && event.kind == "progress" -> "Saving files"
|
||||||
|
event.phase == "transfer" && event.kind == "progress" -> "Sending to receiver"
|
||||||
|
event.phase == "transfer" && event.kind == "started" -> "Receiver connected"
|
||||||
|
event.phase == "transfer" && event.kind == "completed" -> "Send completed"
|
||||||
|
event.phase == "lifecycle" && event.kind == "done" -> "Completed"
|
||||||
|
event.phase == "lifecycle" && event.kind == "cancelled" -> "Cancelled"
|
||||||
|
event.kind == "failed" -> "Failed"
|
||||||
|
else -> listOfNotNull(
|
||||||
|
event.direction?.replaceFirstChar { it.uppercase() },
|
||||||
|
event.phase.replaceFirstChar { it.uppercase() },
|
||||||
|
event.kind.replace('-', ' '),
|
||||||
|
).joinToString(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseProgress(json: String): Float? {
|
private fun progressDetail(event: CoreEventModel): String? {
|
||||||
val transferred = findNumber(json, "transferred") ?: findNumber(json, "downloaded") ?: findNumber(json, "written")
|
val fileName = findString(event.dataJson, "file_name")
|
||||||
val total = findNumber(json, "total") ?: findNumber(json, "total_size")
|
val current = findNumber(event.dataJson, "current_file_index")?.toLong()
|
||||||
|
val totalFiles = findNumber(event.dataJson, "total_files")?.toLong()
|
||||||
|
return when {
|
||||||
|
fileName != null && current != null && totalFiles != null && totalFiles > 0 ->
|
||||||
|
"$fileName (${current + 1}/$totalFiles)"
|
||||||
|
fileName != null -> fileName
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseProgress(json: String, sizeHint: Double? = null): Float? {
|
||||||
|
val transferred = findNumber(json, "exported")
|
||||||
|
?: findNumber(json, "downloaded")
|
||||||
|
?: findNumber(json, "offset")
|
||||||
|
?: findNumber(json, "end_offset")
|
||||||
|
?: findNumber(json, "transferred")
|
||||||
|
?: findNumber(json, "written")
|
||||||
|
val total = findNumber(json, "file_size")
|
||||||
|
?: findNumber(json, "total_size")
|
||||||
|
?: findNumber(json, "size")
|
||||||
|
?: findNumber(json, "total")
|
||||||
|
?: sizeHint
|
||||||
if (transferred == null || total == null || total <= 0.0) return null
|
if (transferred == null || total == null || total <= 0.0) return null
|
||||||
return (transferred / total).toFloat().coerceIn(0f, 1f)
|
return (transferred / total).toFloat().coerceIn(0f, 1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findKnownSize(events: List<CoreEventModel>, transferId: ULong): Double? {
|
||||||
|
for (event in events) {
|
||||||
|
if (event.transferId != transferId) continue
|
||||||
|
findNumber(event.dataJson, "size")?.takeIf { it > 0 }?.let { return it }
|
||||||
|
findNumber(event.dataJson, "total_size")?.takeIf { it > 0 }?.let { return it }
|
||||||
|
findNumber(event.dataJson, "file_size")?.takeIf { it > 0 }?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun findNumber(json: String, key: String): Double? {
|
private fun findNumber(json: String, key: String): Double? {
|
||||||
val marker = "\"$key\":"
|
val marker = "\"$key\":"
|
||||||
val start = json.indexOf(marker)
|
val start = json.indexOf(marker)
|
||||||
if (start < 0) return null
|
if (start < 0) return null
|
||||||
val valueStart = start + marker.length
|
val valueStart = start + marker.length
|
||||||
val valueEnd = json.indexOfAny(charArrayOf(',', '}'), valueStart).takeIf { it >= 0 } ?: json.length
|
val valueEnd = json.indexOfAny(charArrayOf(',', '}', ']'), valueStart).takeIf { it >= 0 } ?: json.length
|
||||||
return json.substring(valueStart, valueEnd).trim().toDoubleOrNull()
|
return json.substring(valueStart, valueEnd).trim().trim('"').toDoubleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findString(json: String, key: String): String? {
|
||||||
|
val marker = "\"$key\":"
|
||||||
|
val start = json.indexOf(marker)
|
||||||
|
if (start < 0) return null
|
||||||
|
val after = json.substring(start + marker.length).trimStart()
|
||||||
|
if (!after.startsWith('"')) return null
|
||||||
|
val end = after.indexOf('"', 1)
|
||||||
|
if (end <= 1) return null
|
||||||
|
return after.substring(1, end)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -364,6 +364,23 @@ class ViewModelsTest {
|
|||||||
assertEquals("ticket-xyz", viewModel.state.value.ticket)
|
assertEquals("ticket-xyz", viewModel.state.value.ticket)
|
||||||
assertFalse(viewModel.state.value.isReceiving)
|
assertFalse(viewModel.state.value.isReceiving)
|
||||||
assertTrue(viewModel.state.value.inspection != null)
|
assertTrue(viewModel.state.value.inspection != null)
|
||||||
|
assertEquals("sender refused", viewModel.state.value.lastReceiveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun receiveViewModelCancelUsesActiveTransferId() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
mutableState.value = CoreState(
|
||||||
|
isInitialized = true,
|
||||||
|
transfers = listOf(receivedTransfer(33UL, TransferStatus.Receiving)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.cancelActiveReceive()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf(33UL), core.cancelledTransfers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
var deleteResult: Result<Unit> = Result.success(Unit)
|
var deleteResult: Result<Unit> = Result.success(Unit)
|
||||||
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
|
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
|
||||||
val deletedTransfers = mutableListOf<ULong>()
|
val deletedTransfers = mutableListOf<ULong>()
|
||||||
|
val cancelledTransfers = mutableListOf<ULong>()
|
||||||
var clearReceiveHistoryCount = 0
|
var clearReceiveHistoryCount = 0
|
||||||
var receiveCount = 0
|
var receiveCount = 0
|
||||||
var lastReceiveTicket: String? = null
|
var lastReceiveTicket: String? = null
|
||||||
@@ -127,7 +128,10 @@ class FakeCoreGateway : CoreGateway {
|
|||||||
awaitReceiveIfNeeded()
|
awaitReceiveIfNeeded()
|
||||||
return receiveResult
|
return receiveResult
|
||||||
}
|
}
|
||||||
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
|
override suspend fun cancel(transferId: ULong): Result<Unit> {
|
||||||
|
cancelledTransfers += transferId
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
override suspend fun delete(transferId: ULong): Result<Unit> {
|
override suspend fun delete(transferId: ULong): Result<Unit> {
|
||||||
if (deleteResult.isSuccess) {
|
if (deleteResult.isSuccess) {
|
||||||
deletedTransfers += transferId
|
deletedTransfers += transferId
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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.CoreEventModel
|
||||||
import com.vnidrop.app.core.Transfer
|
import com.vnidrop.app.core.Transfer
|
||||||
import com.vnidrop.app.core.ShareAccessPolicy
|
import com.vnidrop.app.core.ShareAccessPolicy
|
||||||
import com.vnidrop.app.core.PickedShareFile
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
@@ -81,6 +82,34 @@ class AppUiModelsTest {
|
|||||||
assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer())
|
assertFalse(storedTransfer(status = TransferStatus.Cancelled).isActiveTransfer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseProgressUnderstandsCorePayloadKeys() {
|
||||||
|
assertEquals(0.5f, parseProgress("""{"offset":50,"size":100}"""))
|
||||||
|
assertEquals(0.25f, parseProgress("""{"exported":25,"file_size":100}"""))
|
||||||
|
assertEquals(0.8f, parseProgress("""{"downloaded":80,"total_size":100}"""))
|
||||||
|
assertEquals(0.1f, parseProgress("""{"end_offset":10}""", sizeHint = 100.0))
|
||||||
|
assertEquals(null, parseProgress("""{"end_offset":10}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressForTransferUsesNewestMatchingEvent() {
|
||||||
|
val events = listOf(
|
||||||
|
event(id = "new", phase = "export", kind = "progress", data = """{"exported":75,"file_size":100,"file_name":"a.bin"}"""),
|
||||||
|
event(id = "old", phase = "export", kind = "progress", data = """{"exported":10,"file_size":100,"file_name":"a.bin"}"""),
|
||||||
|
)
|
||||||
|
val progress = progressForTransfer(events, 7UL)
|
||||||
|
assertEquals(0.75f, progress?.progress)
|
||||||
|
assertEquals("Saving files", progress?.label)
|
||||||
|
assertEquals("a.bin", progress?.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canCancelOnlyActiveStatuses() {
|
||||||
|
assertTrue(storedTransfer(status = TransferStatus.Sharing).canCancelTransfer())
|
||||||
|
assertTrue(storedTransfer(status = TransferStatus.Receiving).canCancelTransfer())
|
||||||
|
assertFalse(storedTransfer(status = TransferStatus.Done).canCancelTransfer())
|
||||||
|
}
|
||||||
|
|
||||||
private fun storedTransfer(status: TransferStatus): Transfer =
|
private fun storedTransfer(status: TransferStatus): Transfer =
|
||||||
Transfer(
|
Transfer(
|
||||||
localId = "local-1",
|
localId = "local-1",
|
||||||
@@ -97,4 +126,21 @@ class AppUiModelsTest {
|
|||||||
createdAt = 1L,
|
createdAt = 1L,
|
||||||
updatedAt = 1L,
|
updatedAt = 1L,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
id: String,
|
||||||
|
phase: String,
|
||||||
|
kind: String,
|
||||||
|
data: String,
|
||||||
|
transferId: ULong = 7UL,
|
||||||
|
) = CoreEventModel(
|
||||||
|
id = id,
|
||||||
|
timestamp = 1L,
|
||||||
|
scope = "transfer",
|
||||||
|
transferId = transferId,
|
||||||
|
direction = "receive",
|
||||||
|
phase = phase,
|
||||||
|
kind = kind,
|
||||||
|
dataJson = data,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user