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:
2026-08-13 19:42:37 +02:00
parent bece2af179
commit 8bb1442338
33 changed files with 3837 additions and 247 deletions

View 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 }
)
}
}
}