feat(apple): send a transfer to a device from the share panel

Send to a device now sits alongside the QR code, NFC, and export actions,
since an offer is another way to deliver the same invitation. Picking a
device pushes the existing transfer rather than re-sharing the files.

The picker lists only devices holding a live grant, so nothing offered there
can fail on tap, and it distinguishes accepted from waiting for that device
to open the app.

Also fixes the deprecated SF Symbol and the two Sendable warnings introduced
with the contacts screen: the sections now talk to the model directly rather
than storing view callbacks that a Binding setter has to convert.
This commit is contained in:
2026-08-07 10:24:05 +02:00
parent d8587851a3
commit 3441280599
23 changed files with 310 additions and 35 deletions

View File

@@ -173,7 +173,7 @@ struct RootView: View {
@ViewBuilder
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .settings:
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)

View File

@@ -66,6 +66,11 @@ protocol CoreGateway: AnyObject {
transferName: String,
senderName: String
) async -> Result<ContactSendOutcome, Error>
/// Offer an existing share to a remembered device, alongside its QR code.
func offerTransferToContact(
transferId: UInt64,
endpointId: String
) async -> Result<ContactSendOutcome, Error>
/// Transfers this device is holding for contacts that were not running.
func heldOffers() async -> Result<[HeldOfferModel], Error>
/// Ask remembered devices whether they hold anything for this one.

View File

@@ -359,6 +359,18 @@ final class CoreRepository: ObservableObject, CoreGateway {
}
}
func offerTransferToContact(
transferId: UInt64,
endpointId: String
) async -> Result<ContactSendOutcome, Error> {
await runCore {
let result = try self.requireCore().offerTransferToContact(
transferId: transferId, endpointId: endpointId
)
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
}
}
func heldOffers() async -> Result<[HeldOfferModel], Error> {
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
}

View File

@@ -110,7 +110,7 @@ private struct PairingSheet: View {
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .laptopcomputerAndIphone)
Image(systemSymbol: .macbookAndIphone)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)

View File

@@ -345,6 +345,32 @@ final class ContactsModel: ObservableObject {
}
}
/// Push an existing transfer to a remembered device.
///
/// Returns whether it landed, so the caller can distinguish "accepted" from
/// "waiting for that device to open the app".
@discardableResult
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
state.busyEndpoints.insert(contact.endpointId)
defer { state.busyEndpoints.remove(contact.endpointId) }
switch await repository.offerTransferToContact(
transferId: transferId,
endpointId: contact.endpointId
) {
case .success(let outcome):
let text: UiText = outcome.delivered
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
: .resource(L10n.Contacts.offerHeld)
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
await refresh()
return outcome.delivered
case .failure(let error):
messages.error(error)
return false
}
}
// MARK: - Management
func setLabel(endpointId: String, label: String) async {

View File

@@ -63,22 +63,9 @@ struct ContactsScreen: View {
}
}
CollectOffersSection(
enabled: model.state.checkForOffersOnOpen,
isChecking: model.state.isCheckingForOffers,
onToggle: model.setCheckForOffersOnOpen,
onCheckNow: {
Task {
let collected = await model.collectWaitingOffers()
if collected == 0 { onNothingWaiting() }
}
}
)
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
GrantLifetimeSection(
selection: model.state.grantLifetime,
onSelect: model.setGrantLifetime
)
GrantLifetimeSection(model: model)
if !model.state.contacts.isEmpty {
Section {
@@ -98,7 +85,7 @@ struct ContactsScreen: View {
private struct ContactsEmptyState: View {
var body: some View {
VStack(spacing: 8) {
Image(systemSymbol: .laptopcomputerAndIphone)
Image(systemSymbol: .macbookAndIphone)
.font(.system(size: 32))
.foregroundStyle(.tint)
Text(String(localized: L10n.Contacts.emptyTitle))
@@ -162,27 +149,33 @@ private struct BlockedRow: View {
}
private struct CollectOffersSection: View {
let enabled: Bool
let isChecking: Bool
let onToggle: (Bool) -> Void
let onCheckNow: () -> Void
@ObservedObject var model: ContactsModel
let onNothingWaiting: () -> Void
var body: some View {
Section {
Toggle(
String(localized: L10n.Contacts.checkOnOpen),
isOn: Binding(get: { enabled }, set: onToggle)
isOn: Binding(
get: { model.state.checkForOffersOnOpen },
set: { model.setCheckForOffersOnOpen($0) }
)
)
Button(action: onCheckNow) {
Button {
Task {
let collected = await model.collectWaitingOffers()
if collected == 0 { onNothingWaiting() }
}
} label: {
HStack {
Text(String(localized: L10n.Contacts.checkNow))
if isChecking {
if model.state.isCheckingForOffers {
Spacer()
ProgressView().controlSize(.small)
}
}
}
.disabled(isChecking)
.disabled(model.state.isCheckingForOffers)
} footer: {
// The privacy cost is the point of the setting, so it is stated
// where the switch is, not buried elsewhere.
@@ -192,14 +185,16 @@ private struct CollectOffersSection: View {
}
private struct GrantLifetimeSection: View {
let selection: GrantLifetimeOption
let onSelect: (GrantLifetimeOption) -> Void
@ObservedObject var model: ContactsModel
var body: some View {
Section {
Picker(
String(localized: L10n.Contacts.grantLifetimeTitle),
selection: Binding(get: { selection }, set: onSelect)
selection: Binding(
get: { model.state.grantLifetime },
set: { model.setGrantLifetime($0) }
)
) {
ForEach(GrantLifetimeOption.allCases) { option in
Text(Self.label(option)).tag(option)

View File

@@ -0,0 +1,71 @@
import SFSafeSymbols
import SwiftUI
/// Picks a remembered device to send an existing transfer to.
///
/// Offered next to the QR code as another way to deliver the same invitation,
/// not as a second share of the same files.
struct DevicePickerSheet: View {
@ObservedObject var model: ContactsModel
let transferId: UInt64
@Environment(\.dismiss) private var dismiss
/// Only devices holding a live grant: the rest cannot be reached until they
/// are paired again, so offering them here would fail on tap.
private var reachable: [DeviceContact] {
model.state.contacts.filter(\.canSend)
}
var body: some View {
NavigationStack {
Group {
if reachable.isEmpty {
ContentUnavailableView {
Label(
String(localized: L10n.Contacts.pickDeviceTitle),
systemSymbol: .macbookAndIphone
)
} description: {
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
}
} else {
List(reachable) { contact in
Button {
Task {
await model.offerTransfer(transferId: transferId, to: contact)
dismiss()
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(contact.displayName)
Text(contact.shortFingerprint)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
if model.state.busyEndpoints.contains(contact.endpointId) {
ProgressView().controlSize(.small)
}
}
}
.disabled(!model.state.busyEndpoints.isEmpty)
}
}
}
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.cancel)) { dismiss() }
}
}
}
.task { await model.refresh() }
#if os(macOS)
.frame(minWidth: 380, minHeight: 320)
#endif
}
}

View File

@@ -5,6 +5,7 @@ import SFSafeSymbols
/// with the composer and detail panels as native sheets and delete as an alert.
struct SendScreen: View {
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let windowClass: WindowClass
/// Transfer pending an inline (list-level) delete confirmation.
@@ -60,7 +61,7 @@ struct SendScreen: View {
onDismissed: model.shareSheetDidDismiss
) {
if let shareTarget {
TransferSharePanel(model: model, transfer: shareTarget)
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
}
}
}
@@ -93,7 +94,7 @@ struct SendScreen: View {
/// alert attached here so they present from the detail's own context (presenting
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
private func detailView(for transfer: Transfer) -> some View {
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
@@ -101,7 +102,7 @@ struct SendScreen: View {
onDismissed: model.shareSheetDidDismiss
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel)
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
}
}
.alert(

View File

@@ -6,6 +6,7 @@ import CoreImage.CIFilterBuiltins
struct TransferDetailsView: View {
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
let events: [CoreEventModel]
@State private var showStopConfirmation = false
@@ -129,6 +130,7 @@ private struct DetailDestination: View {
struct DetailPanelContent: View {
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
let panel: TransferDetailPanel
@@ -146,7 +148,7 @@ struct DetailPanelContent: View {
onAccept: model.acceptReceiver
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
}
}
}
@@ -296,6 +298,7 @@ private struct ReceiverRow: View {
struct TransferSharePanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
var body: some View {
@@ -309,7 +312,7 @@ struct TransferSharePanel: View {
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
}
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
case .preparing:
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable:

View File

@@ -20,14 +20,25 @@ protocol TransferShareActions: AnyObject {
struct ShareActionsView: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
let ticket: String
@State private var actions: TransferShareActions = makePlatformShareActions()
@State private var writingNfc = false
@State private var choosingDevice = false
var body: some View {
VStack(spacing: 12) {
// Sending straight to a remembered device is another way to deliver
// this same invitation, so it belongs with the other delivery
// methods rather than in a separate flow.
if contacts.state.contacts.contains(where: \.canSend) {
SecondaryButton(
title: String(localized: L10n.Contacts.sendToDevice),
action: { choosingDevice = true }
)
}
if actions.nfcAvailability != .hidden {
SecondaryButton(
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
@@ -57,5 +68,8 @@ struct ShareActionsView: View {
}, enabled: actions.canUseNativeShare)
}
.onDisappear { actions.cancelNfcWrite() }
.sheet(isPresented: $choosingDevice) {
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
}
}
}

View File

@@ -57,7 +57,7 @@ struct SettingsScreen: View {
}
NavigationLink(value: SettingsSection.contacts) {
SettingsRow(
icon: .laptopcomputerAndIphone,
icon: .macbookAndIphone,
title: String(localized: L10n.Contacts.title),
value: contacts.state.contacts.isEmpty
? nil