Files
vnidrop/apple/VniDrop/Features/Send/SendScreen.swift
cdricms 5424da855e fix(apple): show receiver-approval modal on macOS release builds
The approval modal never appeared for a macOS sender: the receiver request
reached the core and even fired its notification, but the modal stayed hidden.

Root cause was observation, not presentation. `RootView` derived `approvals`
and `messages` as `@ObservedObject` in `init` from a freshly built `AppGraph`.
`init` runs on every view re-creation and each run makes a throwaway graph, so
those observed objects were repointed to a dead `ApprovalCoordinator` that never
receives core events — while the persisted `@StateObject graph` (and the models
wired to it) kept the live one. Debug happened not to re-init the view, so it
stayed on the live instance; release re-inits it, exposing the bug.

Move the snackbar + approval modal into an `OverlayLayer` child view that takes
the coordinator/messages as `@ObservedObject` and is constructed in `body` from
the persisted `graph`, so the subscription is always against the live instances.

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
2026-07-31 11:19:28 +02:00

274 lines
9.4 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
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, 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, 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, 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
}
}
}