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: one state each, receiving side only. private var primaryAction: TransferAction? { if transfer.canReceive { return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive) } if transfer.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) { // Never disabled: a create in flight can take minutes against an // unavailable device, and a dead Close left no way out at all. // It reads "Cancel" while waiting, because that is what leaving // now does — abandoning keeps the sources alive for the core's // import and cleans them up once the call lands. Button(String(localized: model.state.isCreatingSend ? L10n.Button.cancel : L10n.Button.close)) { if model.state.isCreatingSend { model.abandonSend() } else { model.cancelSend() } } } } } .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 ) -> 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 } ) } } }