mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +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.
275 lines
9.5 KiB
Swift
275 lines
9.5 KiB
Swift
import SwiftUI
|
|
import SFSafeSymbols
|
|
|
|
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
|
|
/// with the composer and detail panels as native sheets and delete as an alert.
|
|
struct SendScreen: View {
|
|
@ObservedObject var model: SendModel
|
|
@ObservedObject var contacts: ContactsModel
|
|
let windowClass: WindowClass
|
|
|
|
/// Transfer pending an inline (list-level) delete confirmation.
|
|
@State private var deleteTarget: Transfer?
|
|
|
|
private var outgoing: [Transfer] {
|
|
model.coreState.transfers.filter { $0.direction == .send }
|
|
}
|
|
/// The transfer whose list-level share sheet is open, resolved from the model's
|
|
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
|
|
private var shareTarget: Transfer? {
|
|
guard let id = model.state.shareTargetId else { return nil }
|
|
return outgoing.first { $0.transferId == id }
|
|
}
|
|
private var selectedTransfer: Transfer? {
|
|
guard let id = model.state.selectedTransferId else { return nil }
|
|
return outgoing.first { $0.transferId == id }
|
|
}
|
|
|
|
private var detailsBinding: Binding<Bool> {
|
|
Binding(get: { model.state.selectedTransferId != nil }, set: { if !$0 { model.closeTransferDetails() } })
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if outgoing.isEmpty {
|
|
emptyState
|
|
} else {
|
|
catalog
|
|
}
|
|
}
|
|
.navigationTitle(Text(String(localized: L10n.Send.title)))
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button(action: model.openComposer) {
|
|
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
|
}
|
|
}
|
|
}
|
|
.navigationDestination(isPresented: detailsBinding) {
|
|
if let transfer = selectedTransfer {
|
|
detailView(for: transfer)
|
|
}
|
|
}
|
|
// Attached inside the NavigationStack (a different sheet host than the
|
|
// composer drawer on the outer body, so the two don't clash). Opens the
|
|
// share panel over the list without navigating into the transfer detail.
|
|
.adaptiveDrawer(
|
|
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
|
|
windowClass: windowClass,
|
|
onDismiss: model.closeShareTarget,
|
|
onDismissed: model.shareSheetDidDismiss
|
|
) {
|
|
if let shareTarget {
|
|
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
|
|
}
|
|
}
|
|
}
|
|
.adaptiveDrawer(
|
|
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
|
windowClass: windowClass,
|
|
onDismiss: model.dismissComposer
|
|
) {
|
|
TransferComposer(model: model, windowClass: windowClass)
|
|
}
|
|
.alert(
|
|
Text(String(localized: L10n.Transfer.deleteTitle)),
|
|
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } })
|
|
) {
|
|
Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil }
|
|
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) {
|
|
if let target = deleteTarget { model.deleteTransfer(id: target.transferId) }
|
|
deleteTarget = nil
|
|
}
|
|
} message: {
|
|
if let target = deleteTarget {
|
|
Text(L10n.Transfer.deleteDescription(
|
|
transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// The pushed transfer details view, with its detail-panel sheet and delete
|
|
/// alert attached here so they present from the detail's own context (presenting
|
|
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
|
private func detailView(for transfer: Transfer) -> some View {
|
|
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
|
|
.adaptiveDrawer(
|
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
|
windowClass: windowClass,
|
|
onDismiss: model.closeDetailPanel,
|
|
onDismissed: model.shareSheetDidDismiss
|
|
) {
|
|
if let panel = model.state.detailPanel {
|
|
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
|
|
}
|
|
}
|
|
.alert(
|
|
Text(String(localized: L10n.Transfer.deleteTitle)),
|
|
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
|
|
) {
|
|
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
|
|
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
|
|
} message: {
|
|
Text(L10n.Transfer.deleteDescription(
|
|
transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
|
}
|
|
}
|
|
|
|
private var catalog: some View {
|
|
List {
|
|
Section {
|
|
ForEach(outgoing) { transfer in
|
|
Button {
|
|
model.openTransfer(transfer.transferId)
|
|
} label: {
|
|
TransferListItem(
|
|
transfer: transfer,
|
|
thumbnail: model.state.transferThumbnails[transfer.transferId],
|
|
progress: progress(for: transfer)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
if transfer.ticket != nil {
|
|
Button {
|
|
model.openShareTarget(transfer.transferId)
|
|
} label: {
|
|
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
|
}
|
|
}
|
|
if transfer.status == .sharing {
|
|
Button(role: .destructive) {
|
|
model.stopSharing(transferId: transfer.transferId)
|
|
} label: {
|
|
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
|
}
|
|
}
|
|
Divider()
|
|
Button(role: .destructive) {
|
|
deleteTarget = transfer
|
|
} label: {
|
|
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text(String(localized: L10n.Send.transfersTitle))
|
|
} footer: {
|
|
Text(String(localized: L10n.Send.subtitle))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
|
|
} description: {
|
|
Text(String(localized: L10n.Send.emptyBody))
|
|
} actions: {
|
|
Button(action: model.openComposer) {
|
|
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
|
|
private func progress(for transfer: Transfer) -> TransferProgress? {
|
|
switch transfer.status {
|
|
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
|
|
case .sharing: return sharingProgress(for: transfer)
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
/// Progress for an active share, driven by receivers whose delivery is still
|
|
/// in flight (`.accepted`). Returns nil when none are downloading, so the bar
|
|
/// clears once every receiver has completed even if byte events lag.
|
|
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
|
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
|
if active.isEmpty { return nil }
|
|
let fractions = active.compactMap {
|
|
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
|
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
|
|
}
|
|
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
|
if active.count == 1 {
|
|
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
|
labelKey: L10n.Progress.sending, progress: combined)
|
|
}
|
|
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
|
labelKey: L10n.Progress.sending, progress: combined,
|
|
label: L10n.Progress.sendingToCount(count: active.count))
|
|
}
|
|
}
|
|
|
|
private struct TransferListItem: View {
|
|
let transfer: Transfer
|
|
let thumbnail: Data?
|
|
let progress: TransferProgress?
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
FileArtwork(thumbnail: thumbnail)
|
|
.frame(width: 40, height: 40)
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack {
|
|
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
|
.font(.body).lineLimit(1)
|
|
Spacer()
|
|
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
|
|
}
|
|
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: accessPolicyLabel(transfer.accessPolicy)))
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
if let progress, transfer.status == .importing || transfer.status == .sharing {
|
|
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
Image(systemSymbol: .chevronForward)
|
|
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
struct FileArtwork: View {
|
|
let thumbnail: Data?
|
|
|
|
var body: some View {
|
|
if let thumbnail, let image = PlatformImage.from(data: thumbnail) {
|
|
image.resizable().aspectRatio(contentMode: .fill)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
} else {
|
|
Image(systemSymbol: .doc)
|
|
.font(.system(size: 18))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
func statusLabel(_ status: TransferStatus) -> String {
|
|
String(localized: statusLabelKey(status))
|
|
}
|
|
|
|
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
|
|
switch policy {
|
|
case .requireApproval: return String(localized: L10n.Send.accessApproval)
|
|
case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
|
|
}
|
|
}
|
|
|
|
extension TransferStatus {
|
|
var pillTone: PillTone {
|
|
switch self {
|
|
case .sharing, .done: return .brand
|
|
case .importing, .receiving: return .warning
|
|
case .failed, .cancelled: return .destructive
|
|
case .stopped: return .neutral
|
|
}
|
|
}
|
|
}
|