mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
Send to a device now sits alongside the QR code, NFC, and export actions, since an offer is another way to deliver the same invitation. Picking a device pushes the existing transfer rather than re-sharing the files. The picker lists only devices holding a live grant, so nothing offered there can fail on tap, and it distinguishes accepted from waiting for that device to open the app. Also fixes the deprecated SF Symbol and the two Sendable warnings introduced with the contacts screen: the sections now talk to the model directly rather than storing view callbacks that a Binding setter has to convert.
442 lines
14 KiB
Swift
442 lines
14 KiB
Swift
import SwiftUI
|
|
import SFSafeSymbols
|
|
import CoreImage.CIFilterBuiltins
|
|
|
|
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
|
|
|
|
struct TransferDetailsView: View {
|
|
@ObservedObject var model: SendModel
|
|
@ObservedObject var contacts: ContactsModel
|
|
let transfer: Transfer
|
|
let events: [CoreEventModel]
|
|
@State private var showStopConfirmation = false
|
|
|
|
private var isActiveShare: Bool {
|
|
transfer.status == .sharing || transfer.status == .importing
|
|
}
|
|
|
|
private var pendingReceivers: Int {
|
|
model.state.receiverHistory.filter { $0.status == .requested || $0.status == .accepted }.count
|
|
}
|
|
private var completedReceivers: Int {
|
|
model.state.receiverHistory.filter { $0.status == .completed }.count
|
|
}
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status))
|
|
LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize))
|
|
LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy))
|
|
} header: {
|
|
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
|
}
|
|
|
|
Section {
|
|
DetailDestination(
|
|
title: String(localized: L10n.Transfer.activityTitle),
|
|
description: String(localized: L10n.Transfer.activityDescription),
|
|
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
|
|
onTap: model.openActivity
|
|
)
|
|
DetailDestination(
|
|
title: String(localized: L10n.Transfer.receiversTitle),
|
|
description: receiversDescription(pendingReceivers, completedReceivers),
|
|
count: pendingReceivers + completedReceivers,
|
|
onTap: model.openReceivers
|
|
)
|
|
}
|
|
|
|
Section {
|
|
if isActiveShare {
|
|
Button(role: .destructive) {
|
|
showStopConfirmation = true
|
|
} label: {
|
|
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
|
}
|
|
}
|
|
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button(action: model.openShare) {
|
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
|
}
|
|
.help(String(localized: L10n.Transfer.shareTitle))
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
Text(String(localized: L10n.Send.stopSharing)),
|
|
isPresented: $showStopConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
|
|
model.stopSharing(transferId: transfer.transferId)
|
|
}
|
|
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
|
} message: {
|
|
Text(String(localized: L10n.Send.stopSharingDescription))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
|
|
if pending > 0 && completed > 0 {
|
|
return L10n.Format.separatedPair(
|
|
first: L10n.Transfer.receiversPending(count: pending),
|
|
second: L10n.Transfer.receiversCompletedCount(count: completed))
|
|
}
|
|
if pending > 0 { return L10n.Transfer.receiversPending(count: pending) }
|
|
if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) }
|
|
return String(localized: L10n.Transfer.receiversDescription)
|
|
}
|
|
|
|
private struct DetailDestination: View {
|
|
let title: String
|
|
let description: String
|
|
let count: Int
|
|
let onTap: () -> Void
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(title).foregroundStyle(.primary)
|
|
Text(description).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if count > 0 {
|
|
Text(verbatim: "\(count)")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Image(systemSymbol: .chevronForward)
|
|
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
// MARK: - Detail panels
|
|
|
|
struct DetailPanelContent: View {
|
|
@ObservedObject var model: SendModel
|
|
@ObservedObject var contacts: ContactsModel
|
|
let transfer: Transfer
|
|
let panel: TransferDetailPanel
|
|
|
|
var body: some View {
|
|
switch panel {
|
|
case .activity:
|
|
TransferActivityPanel(events: model.coreState.events, transferId: transfer.transferId)
|
|
case .receivers:
|
|
ReceiverHistoryPanel(
|
|
receivers: model.state.receiverHistory,
|
|
loading: model.state.isLoadingReceivers,
|
|
events: model.coreState.events,
|
|
transferTotalSize: transfer.totalSize,
|
|
onCancel: model.cancelReceiver,
|
|
onAccept: model.acceptReceiver
|
|
)
|
|
case .share:
|
|
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PanelContainer<Content: View>: View {
|
|
let title: String
|
|
@ViewBuilder let content: () -> Content
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(title).font(VniType.titleLarge)
|
|
content()
|
|
}
|
|
.padding(.horizontal, 20).padding(.vertical, 14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
struct TransferActivityPanel: View {
|
|
@Environment(\.vniColors) private var colors
|
|
let events: [CoreEventModel]
|
|
let transferId: UInt64
|
|
|
|
var body: some View {
|
|
let visible = events
|
|
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
|
|
.sorted { $0.timestamp > $1.timestamp }
|
|
PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
|
|
if visible.isEmpty {
|
|
Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter)
|
|
} else {
|
|
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
|
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
|
Text(String(localized: event.activityTitleKey))
|
|
.fontWeight(.medium).padding(.vertical, 14)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ReceiverHistoryPanel: View {
|
|
@Environment(\.vniColors) private var colors
|
|
let receivers: [ReceiverRequestModel]
|
|
let loading: Bool
|
|
let events: [CoreEventModel]
|
|
let transferTotalSize: UInt64
|
|
let onCancel: (String) -> Void
|
|
let onAccept: (String) -> Void
|
|
|
|
var body: some View {
|
|
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
|
if loading {
|
|
ProgressView().frame(maxWidth: .infinity).padding(40)
|
|
} else if receivers.isEmpty {
|
|
Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter)
|
|
} else {
|
|
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
|
if index > 0 { Divider().overlay(colors.borderDefault) }
|
|
ReceiverRow(
|
|
receiver: receiver,
|
|
sendProgress: sendProgress(for: receiver),
|
|
onCancel: onCancel,
|
|
onAccept: onAccept
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendProgress(for receiver: ReceiverRequestModel) -> TransferProgress? {
|
|
switch receiver.status {
|
|
case .accepted, .requested:
|
|
return progressForReceiver(events: events, transferId: receiver.transferId,
|
|
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transferTotalSize)
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ReceiverRow: View {
|
|
@Environment(\.vniColors) private var colors
|
|
let receiver: ReceiverRequestModel
|
|
let sendProgress: TransferProgress?
|
|
let onCancel: (String) -> Void
|
|
let onAccept: (String) -> Void
|
|
|
|
/// Only pending requests can be cancelled per-receiver: the core rejects a
|
|
/// negative response to an already-accepted request ("...not approved, or it
|
|
/// was refused"). Interrupting an in-flight receiver needs Stop sharing.
|
|
private var isCancelable: Bool {
|
|
receiver.status == .requested
|
|
}
|
|
|
|
var body: some View {
|
|
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
|
|
let showLive = sendProgress != nil && receiver.status != .completed
|
|
&& receiver.status != .refused && receiver.status != .expired
|
|
&& receiver.status != .failed
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
|
if let deviceName = receiver.receiverDeviceName, deviceName != name {
|
|
Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
|
}
|
|
if showLive, let sendProgress {
|
|
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
|
|
} else {
|
|
Text(String(localized: receiver.status.statusTextKey))
|
|
.font(VniType.bodySmall).fontWeight(.medium)
|
|
.foregroundStyle(receiver.status.statusColor(colors))
|
|
}
|
|
if let reason = receiver.reason, !reason.isEmpty {
|
|
Text(receiverReasonUiText(reason).resolved())
|
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
if isCancelable {
|
|
VStack(alignment: .trailing, spacing: 8) {
|
|
Button(role: .destructive) {
|
|
onCancel(receiver.id)
|
|
} label: {
|
|
Text(String(localized: L10n.Button.refuse))
|
|
.font(VniType.bodySmall)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.tint(.red)
|
|
// Fallback approve action, in case the approval modal didn't surface.
|
|
Button {
|
|
onAccept(receiver.id)
|
|
} label: {
|
|
Text(String(localized: L10n.Button.approve))
|
|
.font(VniType.bodySmall).fontWeight(.medium)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 16).padding(.vertical, 7)
|
|
.background(Color.green, in: Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 13)
|
|
}
|
|
}
|
|
|
|
struct TransferSharePanel: View {
|
|
@Environment(\.vniColors) private var colors
|
|
@ObservedObject var model: SendModel
|
|
@ObservedObject var contacts: ContactsModel
|
|
let transfer: Transfer
|
|
|
|
var body: some View {
|
|
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
|
switch transfer.invitationPresentation {
|
|
case .ready(let ticket):
|
|
let qrImage = QRCode.generate(from: ticket)
|
|
qrCard(image: qrImage)
|
|
if qrImage != nil {
|
|
Text(String(localized: L10n.Transfer.scanQr))
|
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
|
|
case .preparing:
|
|
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
|
case .unavailable:
|
|
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
|
|
.foregroundStyle(colors.foregroundLighter)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func qrCard(image: Image?) -> some View {
|
|
ZStack {
|
|
if let image {
|
|
image.interpolation(.none).resizable().scaledToFit().padding(14)
|
|
} else {
|
|
VStack(spacing: 10) {
|
|
Image(systemSymbol: .qrcode)
|
|
.font(.system(size: 36, weight: .medium))
|
|
Text(String(localized: L10n.Transfer.qrUnavailable))
|
|
.font(VniType.bodySmall)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.foregroundStyle(.black.opacity(0.72))
|
|
.padding(22)
|
|
}
|
|
}
|
|
.frame(width: 268, height: 268)
|
|
.background(Color.white, in: RoundedRectangle(cornerRadius: 18))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
enum TransferInvitationPresentation: Equatable {
|
|
case preparing
|
|
case ready(String)
|
|
case unavailable
|
|
}
|
|
|
|
extension Transfer {
|
|
var invitationPresentation: TransferInvitationPresentation {
|
|
switch status {
|
|
case .importing:
|
|
return .preparing
|
|
case .sharing:
|
|
guard let ticket, !ticket.isEmpty else { return .preparing }
|
|
return .ready(ticket)
|
|
case .receiving, .done, .failed, .cancelled, .stopped:
|
|
return .unavailable
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - QR generation (CoreImage)
|
|
|
|
enum QRCode {
|
|
static func generate(from string: String) -> Image? {
|
|
let context = CIContext()
|
|
let filter = CIFilter.qrCodeGenerator()
|
|
filter.message = Data(string.utf8)
|
|
filter.correctionLevel = "M"
|
|
guard let output = filter.outputImage else { return nil }
|
|
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
|
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
|
#if os(iOS)
|
|
return Image(uiImage: UIImage(cgImage: cgImage))
|
|
#else
|
|
return Image(nsImage: NSImage(cgImage: cgImage, size: .zero))
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// MARK: - Event helpers (ported from TransferDetails.kt)
|
|
|
|
extension CoreEventModel {
|
|
var isMeaningfulActivity: Bool {
|
|
(phase == "import" && kind == "started")
|
|
|| (phase == "ticket" && kind == "created")
|
|
|| (phase == "network" && (kind == "connecting" || kind == "connected"))
|
|
|| (phase == "download" && kind == "found-collection")
|
|
|| (phase == "lifecycle" && ["done", "cancelled", "share-stopped"].contains(kind))
|
|
|| ["receiver-requested", "receiver-accepted", "receiver-auto-approved",
|
|
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
|
|
}
|
|
|
|
var activityTitleKey: String.LocalizationValue {
|
|
if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing }
|
|
if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady }
|
|
if phase == "network" { return L10n.Transfer.eventConnecting }
|
|
if phase == "download" { return L10n.Transfer.eventDownloading }
|
|
if phase == "export" { return L10n.Transfer.eventSaving }
|
|
if kind == "receiver-requested" { return L10n.Transfer.eventRequested }
|
|
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved }
|
|
if kind == "receiver-refused" { return L10n.Transfer.eventRefused }
|
|
if kind == "receiver-completed" { return L10n.Transfer.eventCompleted }
|
|
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped }
|
|
if kind == "failed" { return L10n.Transfer.eventFailed }
|
|
return L10n.Transfer.eventUpdated
|
|
}
|
|
}
|
|
|
|
extension ReceiverDeliveryStatus {
|
|
var statusTextKey: String.LocalizationValue {
|
|
switch self {
|
|
case .requested: return L10n.Transfer.receiverRequested
|
|
case .accepted: return L10n.Transfer.receiverAccepted
|
|
case .refused: return L10n.Transfer.receiverRefused
|
|
case .expired: return L10n.Transfer.receiverExpired
|
|
case .completed: return L10n.Transfer.receiverCompleted
|
|
case .failed: return L10n.Transfer.receiverFailed
|
|
case .unknown: return L10n.Transfer.receiverUnknown
|
|
}
|
|
}
|
|
|
|
func statusColor(_ colors: VniDropColors) -> Color {
|
|
switch self {
|
|
case .completed: return colors.brandDefault
|
|
case .refused, .expired, .failed: return colors.destructiveDefault
|
|
default: return colors.foregroundLighter
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|