import SwiftUI import SFSafeSymbols import CoreImage.CIFilterBuiltins /// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`. struct TransferDetailsView: View { @ObservedObject var model: SendModel 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("\(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 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 ) case .share: TransferSharePanel(model: model, transfer: transfer) } } } private struct PanelContainer: 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 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) } } } } 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 /// 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(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) } } .frame(maxWidth: .infinity, alignment: .leading) if isCancelable { Button(role: .destructive) { onCancel(receiver.id) } label: { Text(String(localized: L10n.Button.refuse)) .font(VniType.bodySmall) } .buttonStyle(.borderless) .tint(.red) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 13) } } struct TransferSharePanel: View { @Environment(\.vniColors) private var colors @ObservedObject var model: SendModel 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, 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(systemName: "qrcode") .font(.system(size: 36, weight: .medium)) Text(LocalizedStringKey("transfer_qr_unavailable")) .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