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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
/// Presentation-level saved-device models, ported from
|
||||
/// `feature/saveddevices/SavedDeviceExperienceModels.kt`.
|
||||
|
||||
/// The one consent question currently worth asking. Only one is shown at a time:
|
||||
/// an incoming request always outranks an eligibility we could act on ourselves.
|
||||
enum PairingPrompt: Equatable, Identifiable {
|
||||
/// We completed a qualifying transfer and may ask this peer to pair.
|
||||
case eligibility(peerEndpointId: String, remoteDisplayName: String?)
|
||||
/// This peer asked us; we approve or decline.
|
||||
case incomingRequest(peerEndpointId: String, remoteDisplayName: String?)
|
||||
|
||||
var peerEndpointId: String {
|
||||
switch self {
|
||||
case .eligibility(let id, _), .incomingRequest(let id, _): return id
|
||||
}
|
||||
}
|
||||
|
||||
var remoteDisplayName: String? {
|
||||
switch self {
|
||||
case .eligibility(_, let name), .incomingRequest(_, let name): return name
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .eligibility(let id, _): return "eligibility-\(id)"
|
||||
case .incomingRequest(let id, _): return "incoming-\(id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingPromptState: Equatable {
|
||||
var prompt: PairingPrompt?
|
||||
var busy = false
|
||||
}
|
||||
|
||||
struct TargetedOfferState: Equatable {
|
||||
var pending: [PendingTargetedOfferModel] = []
|
||||
/// Display names for senders we already have saved. A sender we have not
|
||||
/// saved has no trustworthy name, so the UI falls back to a generic label
|
||||
/// rather than rendering a peer-supplied string as if it were verified.
|
||||
var senderDisplayNames: [String: String] = [:]
|
||||
var respondingIds: Set<String> = []
|
||||
|
||||
var current: PendingTargetedOfferModel? { pending.first }
|
||||
|
||||
var currentSenderDisplayName: String? {
|
||||
guard let current else { return nil }
|
||||
return senderDisplayNames[current.senderEndpointId]
|
||||
}
|
||||
}
|
||||
|
||||
enum SavedDeviceTransferDirection: Equatable, Sendable {
|
||||
case outgoing
|
||||
case incoming
|
||||
}
|
||||
|
||||
/// One targeted transfer as the details surface renders it: resolved against the
|
||||
/// local endpoint so "peer" means the *other* device, whichever side we are on.
|
||||
struct SavedDeviceTransferItem: Equatable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let peerEndpointId: String
|
||||
let peerDisplayName: String?
|
||||
let direction: SavedDeviceTransferDirection
|
||||
let transferName: String
|
||||
let fileCount: UInt64
|
||||
let totalSize: UInt64
|
||||
let verifiedBytes: UInt64
|
||||
let state: TargetedTransferStateModel
|
||||
let createdAt: Int64
|
||||
let updatedAt: Int64
|
||||
|
||||
var progressFraction: Double? {
|
||||
guard totalSize > 0, state.isActive || state == .interrupted else { return nil }
|
||||
return min(1, Double(verifiedBytes) / Double(totalSize))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Shared presentation helpers for the saved-device surfaces.
|
||||
|
||||
extension SavedDeviceModel {
|
||||
/// `localLabel`, else the peer's untrusted display-name hint, else a generic
|
||||
/// placeholder. Never falls back to the endpoint ID, which stays diagnostic.
|
||||
var displayName: String {
|
||||
displayNameOrNil ?? String(localized: L10n.Saved.devicesUnnamed)
|
||||
}
|
||||
}
|
||||
|
||||
extension TargetedTransferStateModel {
|
||||
var label: String {
|
||||
switch self {
|
||||
case .preparing: return String(localized: L10n.Status.preparing)
|
||||
case .offering: return String(localized: L10n.Status.offering)
|
||||
case .awaitingApproval: return String(localized: L10n.Status.awaitingApproval)
|
||||
case .approved: return String(localized: L10n.Status.approved)
|
||||
case .connecting: return String(localized: L10n.Status.connecting)
|
||||
case .transferring: return String(localized: L10n.Status.transferring)
|
||||
case .interrupted: return String(localized: L10n.Status.interrupted)
|
||||
case .completed: return String(localized: L10n.Status.completed)
|
||||
case .declined: return String(localized: L10n.Status.declined)
|
||||
case .cancelled: return String(localized: L10n.Status.cancelled)
|
||||
case .failed: return String(localized: L10n.Status.failed)
|
||||
// Deleted transfers are filtered out of the snapshot; label it defensively
|
||||
// rather than crashing if one ever reaches the UI.
|
||||
case .deleted: return String(localized: L10n.Status.cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
var tone: PillTone {
|
||||
switch self {
|
||||
case .completed: return .success
|
||||
case .failed, .declined: return .destructive
|
||||
case .interrupted: return .warning
|
||||
case .transferring, .connecting, .approved: return .brand
|
||||
default: return .neutral
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A device identity avatar. Deliberately generic: the core exposes no trustworthy
|
||||
/// hardware type, so showing a specific device silhouette would imply knowledge
|
||||
/// VniDrop does not have.
|
||||
struct DeviceAvatar: View {
|
||||
var symbol: SFSymbol = .laptopcomputerAndIphone
|
||||
var tint: Color = .secondary
|
||||
var size: CGFloat = 40
|
||||
|
||||
var body: some View {
|
||||
Image(systemSymbol: symbol)
|
||||
.font(.system(size: size * 0.45))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: size, height: size)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: size * 0.225))
|
||||
}
|
||||
}
|
||||
|
||||
/// The endpoint ID, shown as secondary diagnostic detail. Truncated in the middle
|
||||
/// so both ends stay recognizable, and never used as a device's name.
|
||||
struct EndpointIdLabel: View {
|
||||
let endpointId: String
|
||||
|
||||
var body: some View {
|
||||
Text(L10n.Saved.devicesEndpoint(deviceId: endpointId))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
127
apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift
Normal file
127
apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Consent prompts for the saved-device domain: the pairing question and the
|
||||
/// targeted-offer approval. Both are blocking decisions, so each is a native
|
||||
/// alert rather than an inline banner that could be scrolled past.
|
||||
///
|
||||
/// These live outside Experimental Settings — saving a device and approving a
|
||||
/// targeted transfer are top-level product decisions.
|
||||
|
||||
extension View {
|
||||
/// Hosts both saved-device consent prompts. `suppressed` is set while the
|
||||
/// transfer-approval modal is up, so the user is never answering two blocking
|
||||
/// decisions at once — the approval belongs to a transfer this device is
|
||||
/// sending, these belong to a device asking to reach it.
|
||||
func savedDevicePrompts(model: SavedDevicesModel, suppressed: Bool) -> some View {
|
||||
modifier(PairingPromptHost(model: model, suppressed: suppressed))
|
||||
.modifier(TargetedOfferPromptHost(model: model, suppressed: suppressed))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingPromptHost: ViewModifier {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let suppressed: Bool
|
||||
|
||||
private var prompt: PairingPrompt? {
|
||||
suppressed ? nil : model.state.pairingPrompt.prompt
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert(
|
||||
Text(String(localized: title)),
|
||||
isPresented: Binding(
|
||||
get: { prompt != nil },
|
||||
// A swipe/escape dismissal is not an answer: suppress locally
|
||||
// without consuming the core's single-use eligibility. The
|
||||
// `suppressed` guard matters — hiding the alert for the approval
|
||||
// modal also fires this setter, and must not count as a dismissal.
|
||||
set: { if !$0, !suppressed { model.dismissPairingPrompt() } }
|
||||
),
|
||||
presenting: prompt
|
||||
) { prompt in
|
||||
Button(String(localized: acceptLabel(prompt)), action: model.acceptPairingPrompt)
|
||||
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: model.declinePairingPrompt)
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissPairingPrompt)
|
||||
} message: { prompt in
|
||||
messageText(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String.LocalizationValue {
|
||||
switch prompt {
|
||||
case .incomingRequest: return L10n.Pairing.requestTitle
|
||||
case .eligibility, nil: return L10n.Pairing.allowTitle
|
||||
}
|
||||
}
|
||||
|
||||
/// The incoming-request copy names the asking device; the eligibility copy is
|
||||
/// a fixed explanation of what remembering allows.
|
||||
@ViewBuilder
|
||||
private func messageText(_ prompt: PairingPrompt) -> some View {
|
||||
switch prompt {
|
||||
case .incomingRequest:
|
||||
Text(L10n.Pairing.requestBody(device: deviceName(prompt)))
|
||||
case .eligibility:
|
||||
Text(String(localized: L10n.Pairing.allowBody))
|
||||
}
|
||||
}
|
||||
|
||||
private func acceptLabel(_ prompt: PairingPrompt) -> String.LocalizationValue {
|
||||
switch prompt {
|
||||
case .incomingRequest: return L10n.Pairing.accept
|
||||
case .eligibility: return L10n.Saved.devicesRememberAction
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls back to a neutral placeholder: an unsaved peer's name is an untrusted
|
||||
/// hint, and there may be none at all.
|
||||
private func deviceName(_ prompt: PairingPrompt) -> String {
|
||||
prompt.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TargetedOfferPromptHost: ViewModifier {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let suppressed: Bool
|
||||
|
||||
private var offer: PendingTargetedOfferModel? {
|
||||
suppressed ? nil : model.state.targetedOffers.current
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert(
|
||||
// Not the invitation-approval copy: there the remote device asks to
|
||||
// *receive* from us, here it is offering to *send* to us.
|
||||
Text(String(localized: L10n.Targeted.offerTitle)),
|
||||
isPresented: Binding(
|
||||
get: { offer != nil },
|
||||
// Dismissal declines: an unanswered offer would otherwise hold a
|
||||
// slot in the core's bounded per-sender queue. Never while
|
||||
// `suppressed`, though — being hidden behind the approval modal
|
||||
// must not silently decline the sender.
|
||||
set: { if !$0, !suppressed, let offer { model.declineTargetedOffer(offer.transferId) } }
|
||||
),
|
||||
presenting: offer
|
||||
) { offer in
|
||||
Button(String(localized: L10n.Button.approve)) {
|
||||
model.acceptTargetedOffer(offer.transferId)
|
||||
}
|
||||
Button(String(localized: L10n.Button.refuse), role: .destructive) {
|
||||
model.declineTargetedOffer(offer.transferId)
|
||||
}
|
||||
} message: { offer in
|
||||
Text(L10n.Targeted.offerBody(
|
||||
device: senderName,
|
||||
transferName: offer.transferName
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a *saved* sender has a name we can vouch for; anything else stays
|
||||
/// generic rather than rendering a peer-supplied string as verified.
|
||||
private var senderName: String {
|
||||
model.state.targetedOffers.currentSenderDisplayName
|
||||
?? String(localized: L10n.Approval.nearbyDevice)
|
||||
}
|
||||
}
|
||||
618
apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift
Normal file
618
apple/VniDrop/Features/SavedDevices/SavedDevicesModel.swift
Normal file
@@ -0,0 +1,618 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Saved-device feature state, ported from `SavedDevicesViewModel.kt`
|
||||
/// (`SavedDevicesState`). One snapshot drives the list, the details surface, the
|
||||
/// pairing prompt, and the targeted-offer modal.
|
||||
struct SavedDevicesState: Equatable {
|
||||
var isLoading = true
|
||||
var loadFailed = false
|
||||
var eligibilities: [PairingEligibilityModel] = []
|
||||
/// Relationships still awaiting consent on one side or the other. Rendered on
|
||||
/// the main screen alongside saved devices; never usable for a transfer.
|
||||
var pendingRelationships: [DeviceRelationshipModel] = []
|
||||
var savedDevices: [SavedDeviceModel] = []
|
||||
var targetedTransfers: [SavedDeviceTransferItem] = []
|
||||
var pairingPrompt = PairingPromptState()
|
||||
var targetedOffers = TargetedOfferState()
|
||||
/// Peers with a mutation in flight; their row actions are disabled.
|
||||
var busyPeerIds: Set<String> = []
|
||||
var busyTransferIds: Set<String> = []
|
||||
/// Peer whose label editor is open, or nil when closed.
|
||||
var labelingPeerId: String?
|
||||
var labelDraft = ""
|
||||
var isSavingLabel = false
|
||||
/// Peer the user chose to send to, or nil when no composition is open.
|
||||
var sendTargetPeerId: String?
|
||||
/// Sources chosen for the pending targeted send.
|
||||
var sendFiles: [PickedShareFile] = []
|
||||
var sendTransferName = ""
|
||||
var isCreatingSend = false
|
||||
|
||||
/// True when the device being labelled already has one, so "Clear" has
|
||||
/// something to clear.
|
||||
var hasExistingLabel: Bool {
|
||||
guard let peerId = labelingPeerId else { return false }
|
||||
return device(peerId)?.localLabel?.isEmpty == false
|
||||
}
|
||||
|
||||
/// Saving is pointless when the draft matches what is already stored.
|
||||
var canSaveLabel: Bool {
|
||||
guard let peerId = labelingPeerId, !isSavingLabel else { return false }
|
||||
let draft = labelDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let current = device(peerId)?.localLabel ?? ""
|
||||
return draft != current
|
||||
}
|
||||
|
||||
/// A targeted transfer needs a destination, at least one source, and a name —
|
||||
/// the same composition rules as an invitation share.
|
||||
var canCreateTargetedTransfer: Bool {
|
||||
sendTargetPeerId != nil
|
||||
&& !sendFiles.isEmpty
|
||||
&& !sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !isCreatingSend
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
savedDevices.isEmpty && pendingRelationships.isEmpty && eligibilities.isEmpty
|
||||
}
|
||||
|
||||
func transfers(for peerEndpointId: String) -> [SavedDeviceTransferItem] {
|
||||
targetedTransfers.filter { $0.peerEndpointId == peerEndpointId }
|
||||
}
|
||||
|
||||
func device(_ peerEndpointId: String) -> SavedDeviceModel? {
|
||||
savedDevices.first { $0.endpointId == peerEndpointId }
|
||||
}
|
||||
}
|
||||
|
||||
/// Product-level Saved-device experience, ported from `SavedDevicesViewModel.kt`.
|
||||
/// Views observe one snapshot and issue named commands; pairing, targeted offers,
|
||||
/// transfer history, and receive destinations stay internal.
|
||||
@MainActor
|
||||
final class SavedDevicesModel: ObservableObject {
|
||||
@Published private(set) var state = SavedDevicesState()
|
||||
|
||||
/// Requests a file/folder pick, consumed by the view layer (mirrors SendModel).
|
||||
@Published var pendingFilePick = false
|
||||
@Published var pendingFolderPick = false
|
||||
|
||||
private let repository: CoreGateway
|
||||
private let fileSystemService: FileSystemService
|
||||
private let messages: UiMessageController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Serializes `refresh` so overlapping signals cannot interleave their reads
|
||||
/// and publish a torn snapshot (the `refreshMutex` in the KMP model).
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
/// Eligibilities the user dismissed this session. Dismissal is not a decline:
|
||||
/// it suppresses the prompt locally without consuming the core's single-use
|
||||
/// capability, so the device stays actionable from the list.
|
||||
private var dismissedEligibility: Set<String> = []
|
||||
private var receiveFolder: ReceiveFolder?
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
fileSystemService: FileSystemService,
|
||||
preferences: AppPreferencesRepository,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.repository = repository
|
||||
self.fileSystemService = fileSystemService
|
||||
self.messages = messages
|
||||
|
||||
// Re-resolve the destination whenever the configured folder changes, and
|
||||
// load once the core is up. Both inputs gate the first refresh: a receive
|
||||
// has nowhere to land until the folder is known.
|
||||
preferences.$preferences
|
||||
.map(\.receiveFolder)
|
||||
.combineLatest(repository.statePublisher.map(\.isInitialized))
|
||||
.removeDuplicates { $0 == $1 }
|
||||
.sink { [weak self] folder, isInitialized in
|
||||
guard let self else { return }
|
||||
self.receiveFolder = self.fileSystemService.effectiveReceiveFolder(folder)
|
||||
if isInitialized { self.scheduleRefresh() }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
switch signal {
|
||||
case .pairingChanged, .targetedTransferChanged:
|
||||
// Wake-up only: re-read durable state rather than trusting the
|
||||
// event payload (see DESIGN-DEVICE-HISTORY.md §13).
|
||||
if self.repository.state.isInitialized { self.scheduleRefresh() }
|
||||
case .approvalChanged, .receiverHistoryChanged, .transfersChanged:
|
||||
// Invitation-share domain; owned by SendModel.
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
func retry() {
|
||||
guard repository.state.isInitialized, !state.isLoading else { return }
|
||||
scheduleRefresh()
|
||||
}
|
||||
|
||||
// MARK: - Pairing prompt
|
||||
|
||||
func acceptPairingPrompt() {
|
||||
respondToPrompt(accepted: true)
|
||||
}
|
||||
|
||||
func declinePairingPrompt() {
|
||||
respondToPrompt(accepted: false)
|
||||
}
|
||||
|
||||
/// Hides the prompt without answering it. Only meaningful for an eligibility —
|
||||
/// an incoming request stays until it is explicitly answered.
|
||||
func dismissPairingPrompt() {
|
||||
guard let prompt = state.pairingPrompt.prompt else { return }
|
||||
if case .eligibility(let peerId, _) = prompt { dismissedEligibility.insert(peerId) }
|
||||
state.pairingPrompt.prompt = nil
|
||||
}
|
||||
|
||||
private func respondToPrompt(accepted: Bool) {
|
||||
guard let prompt = state.pairingPrompt.prompt, !state.pairingPrompt.busy else { return }
|
||||
state.pairingPrompt.busy = true
|
||||
Task {
|
||||
let result: Result<Void, Error>
|
||||
switch (prompt, accepted) {
|
||||
case (.eligibility(let peerId, _), true):
|
||||
result = await repository.requestSavedDevicePairing(peerEndpointId: peerId).map { _ in () }
|
||||
case (.eligibility(let peerId, _), false):
|
||||
result = await repository.declinePairingEligibility(peerEndpointId: peerId)
|
||||
case (.incomingRequest(let peerId, _), _):
|
||||
result = await repository
|
||||
.respondToDevicePairing(peerEndpointId: peerId, accepted: accepted)
|
||||
.map { _ in () }
|
||||
}
|
||||
state.pairingPrompt.busy = false
|
||||
switch result {
|
||||
case .success: await refresh()
|
||||
case .failure(let error): messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Per-device consent actions
|
||||
|
||||
func rememberEligible(_ peerEndpointId: String) {
|
||||
mutatePeer(peerEndpointId) {
|
||||
await self.repository.requestSavedDevicePairing(peerEndpointId: peerEndpointId).map { _ in () }
|
||||
}
|
||||
}
|
||||
|
||||
func declineEligible(_ peerEndpointId: String) {
|
||||
mutatePeer(peerEndpointId) {
|
||||
await self.repository.declinePairingEligibility(peerEndpointId: peerEndpointId)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptIncoming(_ peerEndpointId: String) {
|
||||
mutatePeer(peerEndpointId) {
|
||||
await self.repository
|
||||
.respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: true)
|
||||
.map { _ in () }
|
||||
}
|
||||
}
|
||||
|
||||
func declineIncoming(_ peerEndpointId: String) {
|
||||
mutatePeer(peerEndpointId) {
|
||||
await self.repository
|
||||
.respondToDevicePairing(peerEndpointId: peerEndpointId, accepted: false)
|
||||
.map { _ in () }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Targeted offers
|
||||
|
||||
func acceptTargetedOffer(_ transferId: String) {
|
||||
respondToTargetedOffer(transferId, accepted: true)
|
||||
}
|
||||
|
||||
func declineTargetedOffer(_ transferId: String) {
|
||||
respondToTargetedOffer(transferId, accepted: false)
|
||||
}
|
||||
|
||||
private func respondToTargetedOffer(_ transferId: String, accepted: Bool) {
|
||||
guard !state.targetedOffers.respondingIds.contains(transferId) else { return }
|
||||
state.targetedOffers.respondingIds.insert(transferId)
|
||||
Task {
|
||||
let response = await repository.respondToTargetedOffer(
|
||||
transferId: transferId, accepted: accepted
|
||||
)
|
||||
switch response {
|
||||
case .success(let outcome):
|
||||
// Approval only authorizes the pull; the receiver still has to run
|
||||
// it. `alreadySettled` is the idempotent replay path and must not
|
||||
// start a second one.
|
||||
if accepted, case .approved(let approvedId) = outcome {
|
||||
if case .failure(let error) = await pullTargetedTransfer(approvedId, resume: false) {
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
state.targetedOffers.respondingIds.remove(transferId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Targeted transfer lifecycle
|
||||
|
||||
func receiveTargetedTransfer(_ transferId: String) {
|
||||
mutateTransfer(transferId) { await self.pullTargetedTransfer(transferId, resume: false) }
|
||||
}
|
||||
|
||||
func resumeTargetedTransfer(_ transferId: String) {
|
||||
mutateTransfer(transferId) { await self.pullTargetedTransfer(transferId, resume: true) }
|
||||
}
|
||||
|
||||
func cancelTargetedTransfer(_ transferId: String) {
|
||||
mutateTransfer(transferId) { await self.repository.cancelTargetedTransfer(id: transferId) }
|
||||
}
|
||||
|
||||
func deleteTargetedTransfer(_ transferId: String) {
|
||||
mutateTransfer(transferId) { await self.repository.deleteTargetedTransfer(id: transferId) }
|
||||
}
|
||||
|
||||
private func pullTargetedTransfer(_ transferId: String, resume: Bool) async -> Result<Void, Error> {
|
||||
guard let folder = receiveFolder else {
|
||||
// Preferences have not resolved a usable destination yet; the pull has
|
||||
// nowhere to land. Surfaces as the generic filesystem error.
|
||||
return .failure(InvitationError.filesystemUnavailable)
|
||||
}
|
||||
let result = resume
|
||||
? await repository.resumeTargetedTransfer(id: transferId, outputDirectoryUrl: folder.value)
|
||||
: await repository.receiveTargetedTransfer(
|
||||
transferId: transferId, outputDirectoryUrl: folder.value
|
||||
)
|
||||
if case .success = result {
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Receive.completed), tone: .success))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
/// Opens targeted-send composition for a saved device.
|
||||
func beginSend(to peerEndpointId: String) {
|
||||
guard !state.busyPeerIds.contains(peerEndpointId) else { return }
|
||||
let discarded = state.sendFiles
|
||||
state.sendTargetPeerId = peerEndpointId
|
||||
state.sendFiles = []
|
||||
state.sendTransferName = ""
|
||||
discardPickedFiles(discarded)
|
||||
}
|
||||
|
||||
func cancelSend() {
|
||||
guard !state.isCreatingSend else { return }
|
||||
let discarded = state.sendFiles
|
||||
state.sendTargetPeerId = nil
|
||||
state.sendFiles = []
|
||||
state.sendTransferName = ""
|
||||
discardPickedFiles(discarded)
|
||||
}
|
||||
|
||||
func selectSendFiles() { pendingFilePick = true }
|
||||
func selectSendFolder() { pendingFolderPick = true }
|
||||
|
||||
func onSendFilesPicked(_ files: [PickedShareFile]) {
|
||||
guard !files.isEmpty else { return }
|
||||
// Replacing a selection discards the picker copies the previous one owned.
|
||||
let selectedValues = Set(files.map(\.value))
|
||||
let discarded = state.sendFiles.filter { !selectedValues.contains($0.value) }
|
||||
state.sendFiles = files
|
||||
state.sendTransferName = defaultTransferName(files)
|
||||
discardPickedFiles(discarded)
|
||||
}
|
||||
|
||||
func onSendFilePickFailed(_ reason: String) {
|
||||
messages.error(reason.isEmpty ? InvitationError.selectionFailed : InvitationError.raw(reason))
|
||||
}
|
||||
|
||||
func removeSendFile(_ value: String) {
|
||||
let discarded = state.sendFiles.filter { $0.value == value }
|
||||
let remaining = state.sendFiles.filter { $0.value != value }
|
||||
// Keep a name the user typed; only re-derive one we generated.
|
||||
let wasDefault = state.sendTransferName == defaultTransferName(state.sendFiles)
|
||||
state.sendFiles = remaining
|
||||
state.sendTransferName = remaining.isEmpty
|
||||
? ""
|
||||
: (wasDefault ? defaultTransferName(remaining) : state.sendTransferName)
|
||||
discardPickedFiles(discarded)
|
||||
}
|
||||
|
||||
func clearSendFiles() {
|
||||
let discarded = state.sendFiles
|
||||
state.sendFiles = []
|
||||
state.sendTransferName = ""
|
||||
discardPickedFiles(discarded)
|
||||
}
|
||||
|
||||
func setSendTransferName(_ value: String) {
|
||||
guard !state.isCreatingSend else { return }
|
||||
state.sendTransferName = value
|
||||
}
|
||||
|
||||
/// Creates the targeted transfer. The receiver still has to approve it — a
|
||||
/// saved device never grants automatic receipt.
|
||||
func createTargetedTransfer() {
|
||||
guard state.canCreateTargetedTransfer, let peerId = state.sendTargetPeerId else { return }
|
||||
let files = state.sendFiles
|
||||
let name = state.sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
state.isCreatingSend = true
|
||||
Task {
|
||||
let result = await fileSystemService.sendPickedFilesToSavedDevice(
|
||||
repository: repository,
|
||||
files: files,
|
||||
transferName: name,
|
||||
receiverEndpointId: peerId
|
||||
)
|
||||
state.isCreatingSend = false
|
||||
switch result {
|
||||
case .success:
|
||||
messages.tryShow(
|
||||
UiMessage(text: .resource(L10n.Saved.devicesSendStarted), tone: .success)
|
||||
)
|
||||
state.sendTargetPeerId = nil
|
||||
state.sendFiles = []
|
||||
state.sendTransferName = ""
|
||||
// The core owns the bytes now; release any picker copies.
|
||||
discardPickedFiles(files)
|
||||
await refresh()
|
||||
case .failure(let error):
|
||||
// Keep the composition intact so the user can retry without
|
||||
// re-picking, mirroring the label editor's failure behavior.
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func defaultTransferName(_ files: [PickedShareFile]) -> String {
|
||||
guard let first = files.first else { return "" }
|
||||
return files.count == 1
|
||||
? first.displayName
|
||||
: L10n.Send.selectedFilesCount(count: files.count)
|
||||
}
|
||||
|
||||
private func discardPickedFiles(_ files: [PickedShareFile]) {
|
||||
guard !files.isEmpty else { return }
|
||||
Task { await fileSystemService.discardPickedFiles(files) }
|
||||
}
|
||||
|
||||
// MARK: - Label editing
|
||||
|
||||
func openLabelEditor(_ peerEndpointId: String) {
|
||||
guard !state.isSavingLabel, !state.busyPeerIds.contains(peerEndpointId) else { return }
|
||||
state.labelingPeerId = peerEndpointId
|
||||
state.labelDraft = state.device(peerEndpointId)?.localLabel ?? ""
|
||||
}
|
||||
|
||||
func setLabelDraft(_ value: String) {
|
||||
// Frozen while saving so the committed value cannot drift from the draft
|
||||
// the user is looking at.
|
||||
guard !state.isSavingLabel else { return }
|
||||
state.labelDraft = value
|
||||
}
|
||||
|
||||
func dismissLabelEditor() {
|
||||
// Refuse to close mid-save: the draft must survive to be retried.
|
||||
guard !state.isSavingLabel else { return }
|
||||
state.labelingPeerId = nil
|
||||
state.labelDraft = ""
|
||||
}
|
||||
|
||||
func saveLabel() {
|
||||
let trimmed = state.labelDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
commitLabel(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
|
||||
func clearLabel() {
|
||||
commitLabel(nil)
|
||||
}
|
||||
|
||||
/// Transactional from the UI's perspective: on failure the draft and the
|
||||
/// editor survive so the user can retry; the editor closes only after the
|
||||
/// core confirms the write.
|
||||
private func commitLabel(_ label: String?) {
|
||||
guard let peerId = state.labelingPeerId else { return }
|
||||
guard !state.isSavingLabel, !state.busyPeerIds.contains(peerId) else { return }
|
||||
state.isSavingLabel = true
|
||||
state.busyPeerIds.insert(peerId)
|
||||
Task {
|
||||
let result = await repository.setSavedDeviceLabel(peerEndpointId: peerId, label: label)
|
||||
switch result {
|
||||
case .success:
|
||||
await refresh()
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Saved.devicesLabeled), tone: .success))
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
state.busyPeerIds.remove(peerId)
|
||||
state.isSavingLabel = false
|
||||
// Only close the editor the user still has open on this peer — they may
|
||||
// have switched to another device while the write was in flight.
|
||||
if case .success = result, state.labelingPeerId == peerId {
|
||||
state.labelingPeerId = nil
|
||||
state.labelDraft = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Destructive actions
|
||||
|
||||
func forget(_ peerEndpointId: String) {
|
||||
mutatePeer(peerEndpointId) {
|
||||
let result = await self.repository.forgetSavedDevice(peerEndpointId: peerEndpointId)
|
||||
if case .success = result {
|
||||
self.messages.tryShow(
|
||||
UiMessage(text: .resource(L10n.Saved.devicesForgotten), tone: .success)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func block(_ peerEndpointId: String) {
|
||||
mutatePeer(peerEndpointId) {
|
||||
let result = await self.repository.blockDevice(peerEndpointId: peerEndpointId)
|
||||
if case .success = result {
|
||||
self.messages.tryShow(
|
||||
UiMessage(text: .resource(L10n.Saved.devicesBlocked), tone: .success)
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mutation helpers
|
||||
|
||||
private func mutatePeer(
|
||||
_ peerEndpointId: String,
|
||||
_ block: @escaping () async -> Result<Void, Error>
|
||||
) {
|
||||
guard !state.busyPeerIds.contains(peerEndpointId) else { return }
|
||||
state.busyPeerIds.insert(peerEndpointId)
|
||||
Task {
|
||||
switch await block() {
|
||||
case .success: await refresh()
|
||||
case .failure(let error): messages.error(error)
|
||||
}
|
||||
state.busyPeerIds.remove(peerEndpointId)
|
||||
}
|
||||
}
|
||||
|
||||
private func mutateTransfer(
|
||||
_ transferId: String,
|
||||
_ block: @escaping () async -> Result<Void, Error>
|
||||
) {
|
||||
guard !state.busyTransferIds.contains(transferId) else { return }
|
||||
state.busyTransferIds.insert(transferId)
|
||||
Task {
|
||||
switch await block() {
|
||||
case .success: await refresh()
|
||||
case .failure(let error): messages.error(error)
|
||||
}
|
||||
state.busyTransferIds.remove(transferId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
/// Coalesces refreshes onto one serial chain. Signals can arrive in bursts;
|
||||
/// without this their reads interleave and publish a torn snapshot.
|
||||
private func scheduleRefresh() {
|
||||
let previous = refreshTask
|
||||
refreshTask = Task {
|
||||
await previous?.value
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() async {
|
||||
state.isLoading = true
|
||||
state.loadFailed = false
|
||||
|
||||
// Five reads make one snapshot; if any fails the snapshot is incomplete, so
|
||||
// surface the failure rather than render a partial list. Sequential by
|
||||
// design: the gateway funnels core calls through one serial dispatcher, so
|
||||
// issuing these concurrently would queue behind each other anyway.
|
||||
do {
|
||||
let eligibilities = try await repository.listPairingEligibilities().get()
|
||||
let relationships = try await repository.listDeviceRelationships().get()
|
||||
let savedDevices = try await repository.listSavedDevices().get()
|
||||
let pendingOffers = try await repository.listPendingTargetedOffers().get()
|
||||
let transfers = try await repository.listTargetedTransfers().get()
|
||||
|
||||
let savedNames = savedDevices.reduce(into: [String: String]()) { names, device in
|
||||
if let name = device.displayNameOrNil { names[device.endpointId] = name }
|
||||
}
|
||||
let localEndpointId = repository.state.status?.endpointId
|
||||
let pendingRelationships = relationships
|
||||
.filter { $0.state == .pendingIncoming || $0.state == .pendingOutgoing }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
|
||||
state.isLoading = false
|
||||
state.loadFailed = false
|
||||
state.eligibilities = eligibilities.sorted { $0.createdAt > $1.createdAt }
|
||||
state.pendingRelationships = pendingRelationships
|
||||
state.savedDevices = savedDevices.sorted { $0.createdAt > $1.createdAt }
|
||||
state.targetedTransfers = transfers
|
||||
.filter { $0.state != .deleted }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
.map { $0.toExperienceItem(localEndpointId: localEndpointId, savedNames: savedNames) }
|
||||
// Leave a prompt mid-answer alone; replacing it would strand the
|
||||
// in-flight request behind a prompt the user never saw.
|
||||
if !state.pairingPrompt.busy {
|
||||
state.pairingPrompt = PairingPromptState(
|
||||
prompt: nextPairingPrompt(
|
||||
relationships: pendingRelationships,
|
||||
eligibilities: eligibilities,
|
||||
savedNames: savedNames
|
||||
)
|
||||
)
|
||||
}
|
||||
state.targetedOffers.pending = pendingOffers.sorted { $0.receivedAt < $1.receivedAt }
|
||||
state.targetedOffers.senderDisplayNames = savedNames
|
||||
} catch {
|
||||
state.isLoading = false
|
||||
state.loadFailed = true
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// An incoming request outranks an eligibility: the peer is waiting on us,
|
||||
/// and answering it is the only action that unblocks them.
|
||||
private func nextPairingPrompt(
|
||||
relationships: [DeviceRelationshipModel],
|
||||
eligibilities: [PairingEligibilityModel],
|
||||
savedNames: [String: String]
|
||||
) -> PairingPrompt? {
|
||||
if let incoming = relationships.first(where: { $0.state == .pendingIncoming }) {
|
||||
let name = eligibilities
|
||||
.first { $0.peerEndpointId == incoming.remoteEndpointId }?
|
||||
.remoteDisplayName
|
||||
?? savedNames[incoming.remoteEndpointId]
|
||||
return .incomingRequest(peerEndpointId: incoming.remoteEndpointId, remoteDisplayName: name)
|
||||
}
|
||||
guard let eligibility = eligibilities.first(where: {
|
||||
!dismissedEligibility.contains($0.peerEndpointId)
|
||||
}) else { return nil }
|
||||
return .eligibility(
|
||||
peerEndpointId: eligibility.peerEndpointId,
|
||||
remoteDisplayName: eligibility.remoteDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension TargetedTransferModel {
|
||||
/// Resolves the transfer against the local endpoint so the UI can speak in
|
||||
/// terms of "the peer" and a direction.
|
||||
func toExperienceItem(
|
||||
localEndpointId: String?,
|
||||
savedNames: [String: String]
|
||||
) -> SavedDeviceTransferItem {
|
||||
let outgoing = senderEndpointId == localEndpointId
|
||||
let peerEndpointId = outgoing ? receiverEndpointId : senderEndpointId
|
||||
return SavedDeviceTransferItem(
|
||||
id: id,
|
||||
peerEndpointId: peerEndpointId,
|
||||
peerDisplayName: savedNames[peerEndpointId],
|
||||
direction: outgoing ? .outgoing : .incoming,
|
||||
transferName: transferName,
|
||||
fileCount: fileCount,
|
||||
totalSize: totalSize,
|
||||
verifiedBytes: verifiedBytes,
|
||||
state: state,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
248
apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift
Normal file
248
apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift
Normal file
@@ -0,0 +1,248 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Saved devices screen. A native `List` of saved devices and outstanding consent
|
||||
/// requests; selecting one opens the per-device details surface. The global
|
||||
/// targeted-transfer history is deliberately absent — transfers belong to a
|
||||
/// device, and are reachable only through it.
|
||||
struct SavedDevicesScreen: View {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
/// Endpoint ID of the device whose details are open.
|
||||
@State private var selectedPeerId: String?
|
||||
|
||||
private var state: SavedDevicesState { model.state }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if state.isLoading && state.isEmpty {
|
||||
loadingState
|
||||
} else if state.loadFailed && state.isEmpty {
|
||||
failedState
|
||||
} else if state.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
deviceList
|
||||
}
|
||||
}
|
||||
// Title-only header once populated: explanatory copy belongs in the
|
||||
// first-use empty state, not above a list the user already understands.
|
||||
.navigationTitle(Text(String(localized: L10n.Saved.devicesListTitle)))
|
||||
}
|
||||
// The consent prompts are hosted at the app root, not here: they must be
|
||||
// answerable from any tab, not only while this screen is showing.
|
||||
.savedDeviceDetails(model: model, windowClass: windowClass, selectedPeerId: $selectedPeerId)
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var deviceList: some View {
|
||||
List {
|
||||
if !state.eligibilities.isEmpty || !state.pendingRelationships.isEmpty {
|
||||
Section {
|
||||
ForEach(state.pendingRelationships) { relationship in
|
||||
PendingRelationshipRow(
|
||||
relationship: relationship,
|
||||
busy: state.busyPeerIds.contains(relationship.remoteEndpointId),
|
||||
onAccept: { model.acceptIncoming(relationship.remoteEndpointId) },
|
||||
onDecline: { model.declineIncoming(relationship.remoteEndpointId) }
|
||||
)
|
||||
}
|
||||
ForEach(pendingEligibilities) { eligibility in
|
||||
EligibilityRow(
|
||||
eligibility: eligibility,
|
||||
busy: state.busyPeerIds.contains(eligibility.peerEndpointId),
|
||||
onRemember: { model.rememberEligible(eligibility.peerEndpointId) },
|
||||
onDecline: { model.declineEligible(eligibility.peerEndpointId) }
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: L10n.Saved.devicesAttentionTitle))
|
||||
}
|
||||
}
|
||||
|
||||
if !state.savedDevices.isEmpty {
|
||||
Section {
|
||||
ForEach(state.savedDevices) { device in
|
||||
Button { selectedPeerId = device.endpointId } label: {
|
||||
SavedDeviceRow(
|
||||
device: device,
|
||||
transferCount: state.transfers(for: device.endpointId).count,
|
||||
busy: state.busyPeerIds.contains(device.endpointId)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu { deviceMenu(device) }
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: L10n.Saved.devicesPendingTitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eligibilities whose peer already has a pending relationship are represented
|
||||
/// by that relationship row instead, so the same device never appears twice.
|
||||
private var pendingEligibilities: [PairingEligibilityModel] {
|
||||
let pendingIds = Set(state.pendingRelationships.map(\.remoteEndpointId))
|
||||
return state.eligibilities.filter { !pendingIds.contains($0.peerEndpointId) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func deviceMenu(_ device: SavedDeviceModel) -> some View {
|
||||
Button {
|
||||
selectedPeerId = device.endpointId
|
||||
} label: {
|
||||
Label(String(localized: L10n.Saved.devicesSendAction), systemSymbol: .paperplane)
|
||||
}
|
||||
Button {
|
||||
selectedPeerId = device.endpointId
|
||||
model.openLabelEditor(device.endpointId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Saved.devicesLabelAction), systemSymbol: .pencil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Placeholder states
|
||||
|
||||
private var loadingState: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text(String(localized: L10n.Saved.devicesLoading))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var failedState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: L10n.Saved.devicesLoadFailed), systemSymbol: .exclamationmarkTriangleFill)
|
||||
} actions: {
|
||||
Button(String(localized: L10n.Button.retry), action: model.retry)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
/// First-use state: the only place that explains what a saved device is.
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .laptopcomputerAndIphone)
|
||||
} description: {
|
||||
Text(String(localized: L10n.Saved.devicesEmpty))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct SavedDeviceRow: View {
|
||||
let device: SavedDeviceModel
|
||||
let transferCount: Int
|
||||
let busy: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
DeviceAvatar()
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(device.displayName)
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
EndpointIdLabel(endpointId: device.endpointId)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if transferCount > 0 {
|
||||
Text(verbatim: "\(transferCount)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Image(systemSymbol: .chevronRight)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
/// A relationship awaiting consent. Incoming requests get accept/decline; an
|
||||
/// outgoing request is informational until the peer answers.
|
||||
private struct PendingRelationshipRow: View {
|
||||
let relationship: DeviceRelationshipModel
|
||||
let busy: Bool
|
||||
let onAccept: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
private var isIncoming: Bool { relationship.state == .pendingIncoming }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
DeviceAvatar(symbol: .personBadgeClock, tint: .orange)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(String(localized: isIncoming
|
||||
? L10n.Saved.devicesPendingIncoming
|
||||
: L10n.Saved.devicesPendingOutgoing))
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
EndpointIdLabel(endpointId: relationship.remoteEndpointId)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if busy { ProgressView().controlSize(.small) }
|
||||
}
|
||||
if isIncoming {
|
||||
HStack(spacing: 10) {
|
||||
Button(String(localized: L10n.Saved.devicesAcceptPairingAction), action: onAccept)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(busy)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer we completed a transfer with and may ask to pair. Naming it uses the
|
||||
/// peer's untrusted hint — the only name available before the device is saved.
|
||||
private struct EligibilityRow: View {
|
||||
let eligibility: PairingEligibilityModel
|
||||
let busy: Bool
|
||||
let onRemember: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
DeviceAvatar(symbol: .checkmarkSealFill, tint: VniDropColors.brandPurple)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(eligibility.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed))
|
||||
.font(.body)
|
||||
.lineLimit(1)
|
||||
Text(String(localized: L10n.Saved.devicesEligibilityTitle))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
EndpointIdLabel(endpointId: eligibility.peerEndpointId)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if busy { ProgressView().controlSize(.small) }
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Button(String(localized: L10n.Saved.devicesRememberAction), action: onRemember)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(busy)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
216
apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift
Normal file
216
apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Source composition for a targeted send. Deliberately mirrors the invitation
|
||||
/// composer's file/folder/rename/replace/clear affordances — the two domains stay
|
||||
/// distinct after creation, but choosing what to send works the same way.
|
||||
struct TargetedSendComposer: View {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let deviceName: String
|
||||
|
||||
private var state: SavedDevicesState { model.state }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if state.sendFiles.isEmpty {
|
||||
chooseStep
|
||||
} else {
|
||||
reviewStep
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.savedDeviceSendPickers(model: model)
|
||||
}
|
||||
|
||||
private var chooseStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(String(localized: L10n.Send.chooseFileTitle))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text(recipientLine)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(spacing: 14) {
|
||||
Image(systemSymbol: .doc)
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(.tint)
|
||||
PrimaryButton(
|
||||
title: String(localized: L10n.Button.chooseFiles),
|
||||
action: model.selectSendFiles
|
||||
)
|
||||
.fixedSize()
|
||||
QuietButton(
|
||||
title: String(localized: L10n.Button.chooseFolder),
|
||||
action: model.selectSendFolder
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(28)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(String(localized: L10n.Send.reviewTitle))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text(recipientLine)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(state.sendFiles) { file in
|
||||
TargetedSourceCard(
|
||||
file: file,
|
||||
canRemove: state.sendFiles.count > 1 && !state.isCreatingSend,
|
||||
onRemove: { model.removeSendFile(file.value) }
|
||||
)
|
||||
}
|
||||
|
||||
Field(
|
||||
label: String(localized: L10n.Field.transferName),
|
||||
value: Binding(
|
||||
get: { state.sendTransferName },
|
||||
set: { model.setSendTransferName($0) }
|
||||
),
|
||||
enabled: !state.isCreatingSend
|
||||
)
|
||||
|
||||
// Every targeted transfer still needs the receiver to approve it;
|
||||
// saying so here sets the right expectation before sending.
|
||||
Label(
|
||||
String(localized: L10n.Send.accessApprovalDescription),
|
||||
systemSymbol: .checkmarkShield
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
private var actions: some View {
|
||||
VStack(spacing: 10) {
|
||||
PrimaryButton(
|
||||
title: String(localized: L10n.Saved.devicesSendAction),
|
||||
action: model.createTargetedTransfer,
|
||||
enabled: state.canCreateTargetedTransfer
|
||||
)
|
||||
HStack(spacing: 10) {
|
||||
sourceButton(
|
||||
title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectSendFiles
|
||||
)
|
||||
sourceButton(
|
||||
title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectSendFolder
|
||||
)
|
||||
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSendFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceButton(
|
||||
title: String.LocalizationValue,
|
||||
symbol: SFSymbol,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Label(String(localized: title), systemSymbol: symbol)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 20)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
.tint(.secondary)
|
||||
.disabled(state.isCreatingSend)
|
||||
}
|
||||
|
||||
private var recipientLine: String {
|
||||
L10n.Saved.devicesTransferDirectionOutgoing(device: deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TargetedSourceCard: View {
|
||||
let file: PickedShareFile
|
||||
let canRemove: Bool
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemSymbol: file.isDirectory ? .folder : .doc)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(file.displayName).lineLimit(1)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if canRemove {
|
||||
Button(role: .destructive, action: onRemove) {
|
||||
Image(systemSymbol: .trash)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
|
||||
if let size = file.sizeBytes { return formatBytes(size) }
|
||||
return String(localized: L10n.Send.fileSizeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
/// File/folder picker for targeted send. Attached to the composer so it presents
|
||||
/// from the composer's own sheet rather than the already-presenting root — the
|
||||
/// same constraint `SendPickers` documents.
|
||||
private struct SavedDeviceSendPickers: ViewModifier {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// One .fileImporter switched between files and folders: stacking two on a
|
||||
// single view silently breaks, with the second shadowing the first.
|
||||
content.fileImporter(
|
||||
isPresented: Binding(
|
||||
get: { model.pendingFilePick || model.pendingFolderPick },
|
||||
set: { presented in
|
||||
if !presented {
|
||||
model.pendingFilePick = false
|
||||
model.pendingFolderPick = false
|
||||
}
|
||||
}
|
||||
),
|
||||
allowedContentTypes: model.pendingFolderPick ? [.folder] : [.item],
|
||||
allowsMultipleSelection: !model.pendingFolderPick
|
||||
) { result in
|
||||
let isDirectory = model.pendingFolderPick
|
||||
model.pendingFilePick = false
|
||||
model.pendingFolderPick = false
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
|
||||
if files.isEmpty {
|
||||
model.onSendFilePickFailed("")
|
||||
} else {
|
||||
model.onSendFilesPicked(files)
|
||||
}
|
||||
case .failure(let error):
|
||||
if !error.isUserCancellation { model.onSendFilePickFailed(error.technicalDetail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func savedDeviceSendPickers(model: SavedDevicesModel) -> some View {
|
||||
modifier(SavedDeviceSendPickers(model: model))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user