diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index dacdc03..7210c35 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -9,15 +9,9 @@ struct RootView: View { @StateObject private var sendModel: SendModel @StateObject private var receiveModel: ReceiveModel @StateObject private var settingsModel: SettingsModel - @ObservedObject private var messages: UiMessageController - @ObservedObject private var approvals: ApprovalCoordinator @Environment(\.scenePhase) private var scenePhase - /// Drives the approval sheet; toggled from the pending-approval `onChange` so the - /// presentation can be deferred until the Share/QR sheet has dismissed on macOS. - @State private var showApproval = false - init(dependencies: AppDependencies) { let graph = AppGraph(dependencies: dependencies) _graph = StateObject(wrappedValue: graph) @@ -50,8 +44,6 @@ struct RootView: View { messages: graph.messages, bugReports: NoopBugReportService() )) - messages = graph.messages - approvals = graph.approvalCoordinator } var body: some View { @@ -60,12 +52,14 @@ struct RootView: View { let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark) ZStack { navigation(windowClass: windowClass) - SnackbarHost(controller: messages) - ApprovalModalHost( - isPresented: $showApproval, - state: approvals.state, - onAccept: approvals.accept, - onRefuse: approvals.refuse + // Observe the coordinator/messages from the *persisted* `graph` + // StateObject. Deriving them in `init` bound the view to a throwaway + // AppGraph rebuilt on every re-init, whose coordinator never receives + // core events — so the approval modal never appeared. + OverlayLayer( + approvals: graph.approvalCoordinator, + messages: graph.messages, + sendModel: sendModel ) } .overlay { @@ -103,27 +97,6 @@ struct RootView: View { break } } - // A pending approval is a blocking modal. Close the sender's detail panel - // (e.g. the Share/QR sheet) first, then present the approval sheet — but on - // macOS a sheet presented while another is still dismissing is silently - // dropped, so defer the presentation until that dismissal finishes. - .onChange(of: approvals.state.current?.id) { _, id in - guard id != nil else { showApproval = false; return } - let wasShowingSheet = sendModel.state.detailPanel != nil - sendModel.closeDetailPanel() - #if os(macOS) - if wasShowingSheet { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { - if approvals.state.current != nil { showApproval = true } - } - } else { - showApproval = true - } - #else - _ = wasShowingSheet - showApproval = true - #endif - } #if os(macOS) // macOS keeps `scenePhase == .active` even when the app loses focus, so // drive foreground/background off NSApplication's active state instead — @@ -217,6 +190,70 @@ struct RootView: View { } } +/// Hosts the snackbar and the approval modal, observing the coordinator and message +/// controller passed in from the persisted `AppGraph`. Kept as a child view so the +/// `@ObservedObject` subscriptions are established here (in `body`) against the live +/// instances, rather than in `RootView.init` against a throwaway graph. +private struct OverlayLayer: View { + @ObservedObject var approvals: ApprovalCoordinator + @ObservedObject var messages: UiMessageController + let sendModel: SendModel + + /// Drives the approval sheet; toggled from the pending-approval `onChange` so the + /// presentation can be deferred until the Share/QR sheet has dismissed on macOS. + @State private var showApproval = false + + /// macOS-only: an approval arrived while a share/QR sheet was still up. We close + /// that sheet and present the approval once its dismissal completes (see + /// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal. + @State private var approvalAwaitingSheetDismiss = false + + var body: some View { + ZStack { + SnackbarHost(controller: messages) + ApprovalModalHost( + isPresented: $showApproval, + state: approvals.state, + onAccept: approvals.accept, + onRefuse: approvals.refuse + ) + } + // A pending approval is a blocking modal. Close any open share/QR sheet first + // (the detail-view panel *or* the list-level share sheet), then present the + // approval sheet: the approval is presented from the app root and neither + // platform reliably stacks it over a sheet owned by the Send screen. + .onChange(of: approvals.state.current?.id) { _, id in + guard id != nil else { + showApproval = false + approvalAwaitingSheetDismiss = false + return + } + let wasShowingSheet = sendModel.state.detailPanel != nil + || sendModel.state.shareTargetId != nil + sendModel.dismissShareSheets() + #if os(macOS) + // macOS silently drops a sheet presented while another is still dismissing, + // so wait for that sheet's real dismissal completion before presenting. + if wasShowingSheet { + approvalAwaitingSheetDismiss = true + } else { + showApproval = true + } + #else + _ = wasShowingSheet + showApproval = true + #endif + } + #if os(macOS) + .onReceive(sendModel.shareSheetsDismissed) { _ in + guard approvalAwaitingSheetDismiss else { return } + approvalAwaitingSheetDismiss = false + if approvals.state.current != nil { showApproval = true } + } + #endif + } +} + /// A full-window cover with a centered spinner shown while the core is starting. private struct CoreStartingOverlay: View { var body: some View { diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 951bb77..88410cc 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -25,6 +25,11 @@ struct SendState: Equatable { var selectedTransferId: UInt64? var transferThumbnails: [UInt64: Data] = [:] var detailPanel: TransferDetailPanel? + /// Transfer whose share panel is presented inline from the list context menu + /// (distinct from `detailPanel == .share`, which shows it from the detail view). + /// Held in the model — not `SendScreen` @State — so the approval flow can dismiss + /// it centrally before presenting its modal. + var shareTargetId: UInt64? var receiverHistory: [ReceiverRequestModel] = [] var isLoadingReceivers = false var isDeleteConfirmationOpen = false @@ -56,6 +61,18 @@ final class SendModel: ObservableObject { private let messages: UiMessageController private var cancellables = Set() + /// Fires *after* a share/QR sheet (the detail-view panel or the list-level share + /// sheet) has finished animating out. The approval flow waits on this to present + /// its modal on macOS, where a sheet shown while another is still dismissing is + /// dropped — using the real completion instead of a guessed delay. + private let shareSheetsDismissedSubject = PassthroughSubject() + var shareSheetsDismissed: AnyPublisher { + shareSheetsDismissedSubject.eraseToAnyPublisher() + } + + /// Invoked by a share sheet's `onDismiss` completion. + func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) } + init( repository: CoreGateway, fileSystemService: FileSystemService, @@ -201,6 +218,17 @@ final class SendModel: ObservableObject { } func closeDetailPanel() { state.detailPanel = nil } + func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId } + func closeShareTarget() { state.shareTargetId = nil } + + /// Dismisses every share/QR surface at once — the detail-view share panel and the + /// list-level share sheet. Used before presenting the receiver-approval modal, so + /// no competing sheet is left open (macOS drops a sheet shown over another). + func dismissShareSheets() { + state.detailPanel = nil + state.shareTargetId = nil + } + func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true } func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } } @@ -257,8 +285,19 @@ final class SendModel: ObservableObject { /// Uses the core's `respondReceiverRequest` (no backend change); applies to /// receivers that are still pending or accepted. func cancelReceiver(requestId: String) { + respondToReceiver(requestId: requestId, accepted: false) + } + + /// Approves a single pending receiver by responding to its request positively. + /// A fallback for when the approval modal didn't surface — the pending receiver + /// can still be accepted from its row in the transfer's receivers panel. + func acceptReceiver(requestId: String) { + respondToReceiver(requestId: requestId, accepted: true) + } + + private func respondToReceiver(requestId: String, accepted: Bool) { Task { - let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil) + let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil) switch result { case .success: if let transferId = state.selectedTransferId { refreshReceivers(transferId) } diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index 4d73bea..f596ed5 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -7,14 +7,18 @@ struct SendScreen: View { @ObservedObject var model: SendModel let windowClass: WindowClass - /// Transfer whose share panel is presented inline from the list context menu. - @State private var shareTarget: Transfer? /// 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 } @@ -50,9 +54,10 @@ struct SendScreen: View { // 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 { shareTarget = nil } }), + isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }), windowClass: windowClass, - onDismiss: { shareTarget = nil } + onDismiss: model.closeShareTarget, + onDismissed: model.shareSheetDidDismiss ) { if let shareTarget { TransferSharePanel(model: model, transfer: shareTarget) @@ -92,7 +97,8 @@ struct SendScreen: View { .adaptiveDrawer( isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }), windowClass: windowClass, - onDismiss: model.closeDetailPanel + onDismiss: model.closeDetailPanel, + onDismissed: model.shareSheetDidDismiss ) { if let panel = model.state.detailPanel { DetailPanelContent(model: model, transfer: transfer, panel: panel) @@ -127,7 +133,7 @@ struct SendScreen: View { .contextMenu { if transfer.ticket != nil { Button { - shareTarget = transfer + model.openShareTarget(transfer.transferId) } label: { Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp) } diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index b0358fc..a65dd10 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -142,7 +142,8 @@ struct DetailPanelContent: View { loading: model.state.isLoadingReceivers, events: model.coreState.events, transferTotalSize: transfer.totalSize, - onCancel: model.cancelReceiver + onCancel: model.cancelReceiver, + onAccept: model.acceptReceiver ) case .share: TransferSharePanel(model: model, transfer: transfer) @@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View { 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)) { @@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View { } 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) + ReceiverRow( + receiver: receiver, + sendProgress: sendProgress(for: receiver), + onCancel: onCancel, + onAccept: onAccept + ) } } } @@ -224,6 +231,7 @@ private struct ReceiverRow: View { 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 @@ -257,14 +265,27 @@ private struct ReceiverRow: View { } .frame(maxWidth: .infinity, alignment: .leading) if isCancelable { - Button(role: .destructive) { - onCancel(receiver.id) - } label: { - Text(String(localized: L10n.Button.refuse)) - .font(VniType.bodySmall) + 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) } - .buttonStyle(.borderless) - .tint(.red) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift index 7b75752..d991b07 100644 --- a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift +++ b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift @@ -7,11 +7,16 @@ struct AdaptiveDrawer: ViewModifier { @Binding var isPresented: Bool let windowClass: WindowClass let onDismiss: () -> Void + /// Fired after the sheet's dismissal animation completes (as opposed to + /// `onDismiss`, which requests the close). Lets callers serialize a follow-up + /// sheet against this one's actual teardown instead of guessing a delay. + let onDismissed: (() -> Void)? @ViewBuilder let drawerContent: () -> DrawerContent func body(content: Content) -> some View { content.sheet( - isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }) + isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } }), + onDismiss: onDismissed ) { SheetChrome(onClose: onDismiss) { drawerContent() } .modifier(PhoneDetents(enabled: windowClass == .phone)) @@ -56,11 +61,12 @@ extension View { isPresented: Binding, windowClass: WindowClass, onDismiss: @escaping () -> Void, + onDismissed: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> DrawerContent ) -> some View { modifier(AdaptiveDrawer( isPresented: isPresented, windowClass: windowClass, - onDismiss: onDismiss, drawerContent: content + onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content )) } }