mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
feat(apple): saved devices and targeted transfers UI
Adds the native SwiftUI Saved Devices experience on top of the production saved-device core, as a top-level destination in the iOS tab bar and the macOS sidebar. Core seam: - App-facing saved-device domain models mirroring core/SavedDeviceModels.kt, with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views never hand-roll state checks. - 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping. cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial lane: each must reach the core while a targeted receive is blocking it. - Payload-free pairingChanged/targetedTransferChanged signals, dispatched before the numeric-transferId guard since saved-device events identify their subject by peer endpoint or a string transfer id. Experience: - Screen lists saved devices and outstanding consent requests only; the global targeted-transfer history stays out, reachable per device. - Details as a sheet with detents on compact layouts and a native inspector on macOS, owning Send, label, forget/block and that device's transfers. - Label editing is transactional: the draft and editor survive a failed write, conflicting actions are refused while saving, and the editor closes only after the core confirms. - Pairing and targeted-offer consent hosted at the app root, answerable from any tab and suppressed while a transfer approval is up. Dismissing a pairing prompt suppresses locally without consuming the single-use eligibility; dismissing an offer declines it, since an unanswered offer holds a slot in the core's bounded per-sender queue. - Targeted send reuses the invitation composer's affordances with file, folder, rename, replace and cleanup parity. Picker copies are released on replace/remove/clear/cancel and after a successful create, but kept after a failure so retry does not require re-picking. - Notifications for pairing requests and offers (withdrawn once answered) and for terminal targeted transfers. Wording follows direction: on the sending device the peer finished receiving, not us. Localization: - Widens 52 saved-device keys from kmp-only to both platforms. - Five keys carried a literal %1$s with no declared args, which Compose renders positionally but the Apple generator emits as a plain constant, leaking the placeholder into the UI. They now use named args; Compose output is byte-identical. - Adds targeted_offer_title/body. Reusing the invitation approval copy stated the roles backwards, announcing the sender as the receiver. Also surfaces core startup failures: the startup overlay is drawn above the snackbar host, so a failed initialize() was indistinguishable from an app that never finished loading. AppModel now keeps the reason, logs it, and the overlay shows it with a retry, plus the technical detail in DEBUG builds. Send and receive between two devices is verified only partially; a missing endpoint-identity credential currently blocks startup on the test device.
This commit is contained in:
568
apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift
Normal file
568
apple/VniDrop/Features/SavedDevices/SavedDeviceDetailsView.swift
Normal file
@@ -0,0 +1,568 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Per-device details: Send, label/forget/block, and this device's targeted
|
||||
/// transfers with their lifecycle actions. Presented as a sheet on compact
|
||||
/// layouts and as a native inspector on macOS.
|
||||
struct SavedDeviceDetailsView: View {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let peerEndpointId: String
|
||||
let windowClass: WindowClass
|
||||
let onClose: () -> Void
|
||||
|
||||
/// Which destructive action is awaiting confirmation.
|
||||
@State private var confirming: DestructiveAction?
|
||||
|
||||
private enum DestructiveAction: String, Identifiable {
|
||||
case forget
|
||||
case block
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var state: SavedDevicesState { model.state }
|
||||
private var device: SavedDeviceModel? { state.device(peerEndpointId) }
|
||||
private var busy: Bool { state.busyPeerIds.contains(peerEndpointId) }
|
||||
private var transfers: [SavedDeviceTransferItem] { state.transfers(for: peerEndpointId) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let device {
|
||||
content(device)
|
||||
} else {
|
||||
// The device can disappear underneath us — forget, block, or a peer
|
||||
// reinstall all remove it. Close rather than render a stale identity.
|
||||
Color.clear.onAppear(perform: onClose)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text(String(localized: confirming == .block
|
||||
? L10n.Saved.devicesBlockConfirmTitle
|
||||
: L10n.Saved.devicesForgetConfirmTitle)),
|
||||
isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel) { confirming = nil }
|
||||
Button(
|
||||
String(localized: confirming == .block
|
||||
? L10n.Saved.devicesBlockAction
|
||||
: L10n.Saved.devicesForgetAction),
|
||||
role: .destructive
|
||||
) {
|
||||
let action = confirming
|
||||
confirming = nil
|
||||
switch action {
|
||||
case .forget: model.forget(peerEndpointId)
|
||||
case .block: model.block(peerEndpointId)
|
||||
case nil: break
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
let name = device?.displayName ?? String(localized: L10n.Saved.devicesUnnamed)
|
||||
Text(confirming == .block
|
||||
? L10n.Saved.devicesBlockConfirmBody(device: name)
|
||||
: L10n.Saved.devicesForgetConfirmBody(device: name))
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { state.labelingPeerId == peerEndpointId },
|
||||
set: { if !$0 { model.dismissLabelEditor() } }
|
||||
)) {
|
||||
LabelEditorSheet(model: model, windowClass: windowClass)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { state.sendTargetPeerId == peerEndpointId },
|
||||
set: { if !$0 { model.cancelSend() } }
|
||||
)) {
|
||||
TargetedSendSheet(
|
||||
model: model,
|
||||
windowClass: windowClass,
|
||||
deviceName: device?.displayName ?? String(localized: L10n.Saved.devicesUnnamed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(_ device: SavedDeviceModel) -> some View {
|
||||
List {
|
||||
Section { DeviceHeader(device: device) }
|
||||
|
||||
Section {
|
||||
Button {
|
||||
model.beginSend(to: peerEndpointId)
|
||||
} label: {
|
||||
ActionRow(
|
||||
symbol: .paperplaneFill,
|
||||
title: String(localized: L10n.Saved.devicesSendAction),
|
||||
tint: VniDropColors.brandPurple
|
||||
)
|
||||
}
|
||||
.disabled(busy)
|
||||
|
||||
Button {
|
||||
model.openLabelEditor(peerEndpointId)
|
||||
} label: {
|
||||
ActionRow(
|
||||
symbol: .pencil,
|
||||
title: String(localized: L10n.Saved.devicesLabelAction),
|
||||
// Showing the current label makes it clear this renames
|
||||
// locally rather than changing what the peer calls itself.
|
||||
detail: device.localLabel?.isEmpty == false ? device.localLabel : nil
|
||||
)
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
|
||||
Section {
|
||||
if transfers.isEmpty {
|
||||
Text(String(localized: L10n.Saved.devicesTransferEmpty))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 2)
|
||||
} else {
|
||||
ForEach(transfers) { transfer in
|
||||
TargetedTransferRow(
|
||||
transfer: transfer,
|
||||
busy: state.busyTransferIds.contains(transfer.id),
|
||||
onReceive: { model.receiveTargetedTransfer(transfer.id) },
|
||||
onResume: { model.resumeTargetedTransfer(transfer.id) },
|
||||
onCancel: { model.cancelTargetedTransfer(transfer.id) },
|
||||
onDelete: { model.deleteTargetedTransfer(transfer.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: L10n.Saved.devicesTransfersTitle))
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
confirming = .forget
|
||||
} label: {
|
||||
ActionRow(
|
||||
symbol: .trash,
|
||||
title: String(localized: L10n.Saved.devicesForgetAction),
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
.disabled(busy)
|
||||
|
||||
Button(role: .destructive) {
|
||||
confirming = .block
|
||||
} label: {
|
||||
ActionRow(
|
||||
symbol: .nosign,
|
||||
title: String(localized: L10n.Saved.devicesBlockAction),
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
.disabled(busy)
|
||||
} header: {
|
||||
Text(L10n.Saved.devicesMoreActions(device: device.displayName))
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
#else
|
||||
.listStyle(.insetGrouped)
|
||||
#endif
|
||||
.buttonStyle(.plain)
|
||||
.overlay(alignment: .top) {
|
||||
if busy { ProgressView().controlSize(.small).padding(.top, 6) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private struct DeviceHeader: View {
|
||||
let device: SavedDeviceModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 14) {
|
||||
DeviceAvatar(size: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(device.displayName)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
// The authenticated peer-supplied name, shown only when a local
|
||||
// label overrides it, so the user can tell the two apart.
|
||||
if device.localLabel?.isEmpty == false, let remote = device.remoteDisplayName {
|
||||
Text(L10n.Saved.devicesAuthenticatedName(name: remote))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
EndpointIdLabel(endpointId: device.endpointId)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row that reads as a native list action rather than bare tinted text.
|
||||
private struct ActionRow: View {
|
||||
let symbol: SFSymbol
|
||||
let title: String
|
||||
var detail: String? = nil
|
||||
var tint: Color = .primary
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemSymbol: symbol)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(tint == .primary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint))
|
||||
.frame(width: 22)
|
||||
Text(title).foregroundStyle(tint)
|
||||
Spacer(minLength: 8)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// One targeted transfer. The action its state actually calls for is a visible
|
||||
/// button; everything else lives behind an overflow menu so a list of transfers
|
||||
/// does not turn into a wall of buttons.
|
||||
private struct TargetedTransferRow: View {
|
||||
let transfer: SavedDeviceTransferItem
|
||||
let busy: Bool
|
||||
let onReceive: () -> Void
|
||||
let onResume: () -> Void
|
||||
let onCancel: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemSymbol: transfer.direction == .outgoing ? .arrowUpCircle : .arrowDownCircle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(name)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 8)
|
||||
if busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
StatusPill(label: transfer.state.label, tone: transfer.state.tone)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(L10n.Saved.devicesTransferFiles(
|
||||
count: "\(transfer.fileCount)",
|
||||
size: formatBytes(transfer.totalSize)
|
||||
))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if let primary = primaryAction {
|
||||
Button(String(localized: primary.title), action: primary.run)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(busy)
|
||||
}
|
||||
if !overflowActions.isEmpty {
|
||||
Menu {
|
||||
ForEach(overflowActions, id: \.id) { action in
|
||||
Button(String(localized: action.title), role: .destructive, action: action.run)
|
||||
}
|
||||
} label: {
|
||||
Image(systemSymbol: .ellipsisCircle)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.disabled(busy)
|
||||
}
|
||||
}
|
||||
|
||||
if let progress = transfer.progressFraction {
|
||||
ProgressView(value: progress)
|
||||
Text(L10n.Saved.devicesTransferProgress(
|
||||
verified: formatBytes(transfer.verifiedBytes),
|
||||
total: formatBytes(transfer.totalSize)
|
||||
))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var name: String {
|
||||
transfer.transferName.isEmpty
|
||||
? String(localized: L10n.Saved.devicesUnnamed)
|
||||
: transfer.transferName
|
||||
}
|
||||
|
||||
private struct TransferAction {
|
||||
let id: String
|
||||
let title: String.LocalizationValue
|
||||
let run: () -> Void
|
||||
}
|
||||
|
||||
/// At most one of receive/resume applies, and only in one state each.
|
||||
private var primaryAction: TransferAction? {
|
||||
if transfer.state.canReceive {
|
||||
return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive)
|
||||
}
|
||||
if transfer.state.canResume {
|
||||
return TransferAction(id: "resume", title: L10n.Saved.devicesTransferResume, run: onResume)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var overflowActions: [TransferAction] {
|
||||
var actions: [TransferAction] = []
|
||||
if transfer.state.canCancel {
|
||||
actions.append(TransferAction(id: "cancel", title: L10n.Saved.devicesTransferCancel, run: onCancel))
|
||||
}
|
||||
if transfer.state.canDelete {
|
||||
actions.append(TransferAction(id: "delete", title: L10n.Saved.devicesTransferDelete, run: onDelete))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sheets
|
||||
|
||||
/// Label editor. Stays open and keeps its draft when the write fails, and blocks
|
||||
/// its own dismissal while saving, so the retry path always survives.
|
||||
///
|
||||
/// Split per platform on purpose: macOS gets a compact dialog with one trailing
|
||||
/// button row, iOS gets the native Cancel/Save toolbar. A single shared layout
|
||||
/// produced two competing button rows and a lot of dead space.
|
||||
private struct LabelEditorSheet: View {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
private var isSaving: Bool { model.state.isSavingLabel }
|
||||
|
||||
/// The peer's own authenticated name, shown so it is obvious the label is a
|
||||
/// local override rather than a rename on the other device.
|
||||
private var remoteName: String? {
|
||||
guard let peerId = model.state.labelingPeerId else { return nil }
|
||||
guard let remote = model.state.device(peerId)?.remoteDisplayName, !remote.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
private var field: some View {
|
||||
TextField(
|
||||
String(localized: L10n.Saved.devicesLabelPlaceholder),
|
||||
text: Binding(
|
||||
get: { model.state.labelDraft },
|
||||
set: { model.setLabelDraft($0) }
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.labelsHidden()
|
||||
.disabled(isSaving)
|
||||
.onSubmit { if model.state.canSaveLabel { model.saveLabel() } }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(String(localized: L10n.Saved.devicesLabelTitle))
|
||||
.font(.headline)
|
||||
if let remoteName {
|
||||
Text(L10n.Saved.devicesAuthenticatedName(name: remoteName))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
field
|
||||
HStack(spacing: 10) {
|
||||
Button(String(localized: L10n.Saved.devicesLabelClear), role: .destructive) {
|
||||
model.clearLabel()
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
// Nothing to clear when the device has no label yet.
|
||||
.disabled(isSaving || !model.state.hasExistingLabel)
|
||||
Spacer(minLength: 12)
|
||||
if isSaving { ProgressView().controlSize(.small) }
|
||||
Button(String(localized: L10n.Button.cancel), action: model.dismissLabelEditor)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.disabled(isSaving)
|
||||
Button(String(localized: L10n.Saved.devicesLabelSave), action: model.saveLabel)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(isSaving || !model.state.canSaveLabel)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 420)
|
||||
.interactiveDismissDisabled(isSaving)
|
||||
#else
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
field
|
||||
} footer: {
|
||||
if let remoteName {
|
||||
Text(L10n.Saved.devicesAuthenticatedName(name: remoteName))
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button(String(localized: L10n.Saved.devicesLabelClear), role: .destructive) {
|
||||
model.clearLabel()
|
||||
}
|
||||
.disabled(isSaving || !model.state.hasExistingLabel)
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(String(localized: L10n.Saved.devicesLabelTitle)))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.cancel), action: model.dismissLabelEditor)
|
||||
.disabled(isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if isSaving {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(String(localized: L10n.Saved.devicesLabelSave), action: model.saveLabel)
|
||||
.disabled(!model.state.canSaveLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheetSize(windowClass: windowClass, minWidth: 380, minHeight: 240)
|
||||
.interactiveDismissDisabled(isSaving)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts the targeted-send composer.
|
||||
private struct TargetedSendSheet: View {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let windowClass: WindowClass
|
||||
let deviceName: String
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
TargetedSendComposer(model: model, deviceName: deviceName)
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.close), action: model.cancelSend)
|
||||
.disabled(model.state.isCreatingSend)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A create in flight owns the picked sources; discarding them mid-call
|
||||
// would pull the files out from under the core's import.
|
||||
.interactiveDismissDisabled(model.state.isCreatingSend)
|
||||
.sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 480)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
/// Sizes a sheet for its host. A phone must never get a `minWidth` — forcing
|
||||
/// one wider than the screen pushes the content out of bounds instead of
|
||||
/// growing the sheet, which is exactly what a fixed 460pt did.
|
||||
@ViewBuilder
|
||||
func sheetSize(windowClass: WindowClass, minWidth: CGFloat, minHeight: CGFloat) -> some View {
|
||||
if windowClass == .phone {
|
||||
self
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
frame(minWidth: minWidth, minHeight: minHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Adaptive presentation
|
||||
|
||||
extension View {
|
||||
/// Presents the details surface the way each platform expects: a sheet with
|
||||
/// detents on compact layouts, a native inspector on macOS.
|
||||
func savedDeviceDetails(
|
||||
model: SavedDevicesModel,
|
||||
windowClass: WindowClass,
|
||||
selectedPeerId: Binding<String?>
|
||||
) -> some View {
|
||||
modifier(SavedDeviceDetailsPresentation(
|
||||
model: model, windowClass: windowClass, selectedPeerId: selectedPeerId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SavedDeviceDetailsPresentation: ViewModifier {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let windowClass: WindowClass
|
||||
@Binding var selectedPeerId: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(macOS)
|
||||
content.inspector(isPresented: Binding(
|
||||
get: { selectedPeerId != nil },
|
||||
set: { if !$0 { selectedPeerId = nil } }
|
||||
)) {
|
||||
detail
|
||||
.inspectorColumnWidth(min: 300, ideal: 360, max: 480)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
selectedPeerId = nil
|
||||
} label: {
|
||||
Label(String(localized: L10n.Button.close), systemSymbol: .sidebarRight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
content.sheet(isPresented: Binding(
|
||||
get: { selectedPeerId != nil },
|
||||
set: { if !$0 { selectedPeerId = nil } }
|
||||
)) {
|
||||
NavigationStack {
|
||||
detail
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.close)) { selectedPeerId = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 520)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detail: some View {
|
||||
if let peerId = selectedPeerId {
|
||||
SavedDeviceDetailsView(
|
||||
model: model,
|
||||
peerEndpointId: peerId,
|
||||
windowClass: windowClass,
|
||||
onClose: { selectedPeerId = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user