Files
vnidrop/apple/VniDrop/UI/Components/AdaptiveDrawer.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

73 lines
2.1 KiB
Swift

import SwiftUI
/// Presents modal content in a native sheet. On phones it uses medium/large
/// detents with a grabber; on wider layouts the sheet is form-sized. Content is
/// wrapped in a `NavigationStack` so it gets a native title bar + Close button.
struct AdaptiveDrawer<DrawerContent: View>: 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() } }),
onDismiss: onDismissed
) {
SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone))
}
}
}
private struct PhoneDetents: ViewModifier {
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
content.frame(minWidth: 460, minHeight: 480)
}
}
}
private struct SheetChrome<Content: View>: View {
let onClose: () -> Void
@ViewBuilder let content: () -> Content
var body: some View {
NavigationStack {
ScrollView { content().padding(.top, 4) }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.close), action: onClose)
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
extension View {
func adaptiveDrawer<DrawerContent: View>(
isPresented: Binding<Bool>,
windowClass: WindowClass,
onDismiss: @escaping () -> Void,
onDismissed: (() -> Void)? = nil,
@ViewBuilder content: @escaping () -> DrawerContent
) -> some View {
modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, onDismissed: onDismissed, drawerContent: content
))
}
}