feat(apple): contacts screen and consent prompts

Device list, detail, and block list under Settings, plus the two sheets:
an incoming offer and a device asking to be remembered. Both are
answer-only, since swiping away would leave the sender waiting.

Accepting an offer routes the released ticket into the ordinary receive
path, so the platform still chooses the destination. The prompt does not
ask a second time; it only falls back to the review sheet when the
destination is unusable.
This commit is contained in:
2026-08-06 17:57:54 +02:00
parent 256ff89423
commit 1369df4578
7 changed files with 535 additions and 1 deletions

View File

@@ -0,0 +1,133 @@
import SFSafeSymbols
import SwiftUI
/// Consent prompts for device history, presented as sheets like the receiver
/// approval modal.
///
/// Both are dismissable by answering only. An incoming offer in particular must
/// not be acceptable by accident, and a swipe-away would leave the sender
/// waiting on a decision that never comes.
struct ContactPromptHost: View {
/// Driven by the host so a prompt is never presented while another sheet is
/// still animating out macOS silently drops the second one.
@Binding var isPresented: Bool
let state: ContactsState
let onPairingResponse: (String, Bool) -> Void
let onOfferResponse: (String, Bool) -> Void
var body: some View {
Color.clear
.sheet(isPresented: $isPresented) {
// An incoming transfer is the more urgent of the two, and a
// pairing offer keeps until the consent window lapses.
if let offer = state.currentOffer {
OfferSheet(
offer: offer,
busy: state.busyOfferIds.contains(offer.offerId),
onRespond: onOfferResponse
)
.interactiveDismissDisabled(true)
.modifier(ContactPromptDetents())
} else if let pairing = state.currentPairing {
PairingSheet(
pairing: pairing,
busy: state.busyEndpoints.contains(pairing.endpointId),
onRespond: onPairingResponse
)
.interactiveDismissDisabled(true)
.modifier(ContactPromptDetents())
}
}
}
}
private struct ContactPromptDetents: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
content.presentationDetents([.medium])
#else
content.frame(minWidth: 420, minHeight: 300)
#endif
}
}
/// "A remembered device wants to send you files."
private struct OfferSheet: View {
let offer: IncomingOfferModel
let busy: Bool
let onRespond: (String, Bool) -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .trayAndArrowDownFill)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Offer.title))
.font(.title2).fontWeight(.semibold)
Text(L10n.Offer.body(device: offer.resolvedSenderName, transferName: offer.transferName))
.multilineTextAlignment(.center)
Text(L10n.Transfer.fileCount(count: Int(offer.fileCount)))
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
HStack(spacing: 12) {
Button(role: .cancel) {
onRespond(offer.offerId, false)
} label: {
Text(String(localized: L10n.Offer.decline)).frame(maxWidth: .infinity)
}
Button {
onRespond(offer.offerId, true)
} label: {
Text(String(localized: L10n.Offer.accept)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.disabled(busy)
}
.padding(20)
}
}
/// "This device offered to let you reach it. Remember it?"
private struct PairingSheet: View {
let pairing: PendingPairingModel
let busy: Bool
let onRespond: (String, Bool) -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .laptopcomputerAndIphone)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Pairing.requestTitle))
.font(.title2).fontWeight(.semibold)
Text(L10n.Pairing.requestBody(device: pairing.resolvedName))
.multilineTextAlignment(.center)
// Names are peer-supplied; the endpoint id is what actually identifies
// the device.
Text(L10n.Approval.endpointId(deviceId: pairing.endpointId))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer(minLength: 0)
HStack(spacing: 12) {
Button(role: .cancel) {
onRespond(pairing.endpointId, false)
} label: {
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
}
Button {
onRespond(pairing.endpointId, true)
} label: {
Text(String(localized: L10n.Pairing.accept)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.disabled(busy)
}
.padding(20)
}
}

View File

@@ -0,0 +1,280 @@
import SFSafeSymbols
import SwiftUI
/// Device history: the remembered devices, their detail, and the block list.
///
/// Pushed from Settings rather than owning a tab it is a management surface,
/// not part of the send/receive flow.
struct ContactsScreen: View {
@ObservedObject var model: ContactsModel
var body: some View {
Form {
Section {
Text(String(localized: L10n.Contacts.subtitle))
.font(.footnote)
.foregroundStyle(.secondary)
}
if model.state.contacts.isEmpty {
Section {
ContactsEmptyState()
}
} else {
Section(String(localized: L10n.Contacts.title)) {
ForEach(model.state.contacts) { contact in
NavigationLink(value: contact.endpointId) {
ContactRow(contact: contact)
}
}
}
}
if !model.state.blocked.isEmpty {
Section(String(localized: L10n.Contacts.blockedTitle)) {
ForEach(model.state.blocked, id: \.self) { endpointId in
BlockedRow(endpointId: endpointId) {
Task { await model.unblock(endpointId: endpointId) }
}
}
Text(String(localized: L10n.Contacts.unblockHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
GrantLifetimeSection(
selection: model.state.grantLifetime,
onSelect: model.setGrantLifetime
)
if !model.state.contacts.isEmpty {
Section {
ForgetAllButton { Task { await model.forgetAll() } }
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(String(localized: L10n.Contacts.title)))
.navigationDestination(for: String.self) { endpointId in
ContactDetailScreen(model: model, endpointId: endpointId)
}
.task { await model.refresh() }
}
}
private struct ContactsEmptyState: View {
var body: some View {
VStack(spacing: 8) {
Image(systemSymbol: .laptopcomputerAndIphone)
.font(.system(size: 32))
.foregroundStyle(.tint)
Text(String(localized: L10n.Contacts.emptyTitle))
.font(.headline)
Text(String(localized: L10n.Contacts.emptyBody))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
}
private struct ContactRow: View {
let contact: DeviceContact
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(contact.displayName)
if contact.canSend {
if let lastTransferAt = contact.lastTransferAt {
Text(L10n.Contacts.lastTransfer(date: Self.format(lastTransferAt)))
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
// Reachability is derived from holding a live grant, so this is
// the honest signal that sending will not work.
Label(
String(localized: L10n.Contacts.unreachable),
systemSymbol: .exclamationmarkTriangleFill
)
.font(.caption)
.foregroundStyle(.orange)
}
}
}
private static func format(_ millis: Int64) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1_000)
return date.formatted(.relative(presentation: .named))
}
}
private struct BlockedRow: View {
let endpointId: String
let onUnblock: () -> Void
var body: some View {
HStack {
Text(String(endpointId.prefix(16)))
.font(.callout.monospaced())
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button(String(localized: L10n.Contacts.unblock), action: onUnblock)
.buttonStyle(.borderless)
}
}
}
private struct GrantLifetimeSection: View {
let selection: GrantLifetimeOption
let onSelect: (GrantLifetimeOption) -> Void
var body: some View {
Section {
Picker(
String(localized: L10n.Contacts.grantLifetimeTitle),
selection: Binding(get: { selection }, set: onSelect)
) {
ForEach(GrantLifetimeOption.allCases) { option in
Text(Self.label(option)).tag(option)
}
}
Text(String(localized: L10n.Contacts.grantLifetimeHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private static func label(_ option: GrantLifetimeOption) -> String {
guard let days = option.days else {
return String(localized: L10n.Contacts.grantLifetimeNever)
}
return L10n.Contacts.grantLifetimeDays(count: days)
}
}
private struct ForgetAllButton: View {
let onConfirm: () -> Void
@State private var isConfirming = false
var body: some View {
Button(role: .destructive) {
isConfirming = true
} label: {
Text(String(localized: L10n.Contacts.forgetAll))
}
.confirmationDialog(
String(localized: L10n.Contacts.forgetAll),
isPresented: $isConfirming,
titleVisibility: .visible
) {
Button(String(localized: L10n.Contacts.forgetAll), role: .destructive, action: onConfirm)
} message: {
Text(String(localized: L10n.Contacts.forgetBody))
}
}
}
/// Detail for one remembered device: rename, send, forget, block.
struct ContactDetailScreen: View {
@ObservedObject var model: ContactsModel
let endpointId: String
@State private var label = ""
@State private var isConfirmingForget = false
@State private var isConfirmingBlock = false
private var contact: DeviceContact? {
model.state.contacts.first { $0.endpointId == endpointId }
}
var body: some View {
Form {
if let contact {
Section {
TextField(
String(localized: L10n.Contacts.nameField),
text: $label,
prompt: Text(contact.displayName)
)
.onSubmit { commitLabel() }
Text(String(localized: L10n.Contacts.nameHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
// The endpoint id is the only real identity: two devices can
// claim the same name, but not the same key.
LabeledContent(String(localized: L10n.Approval.nearbyDevice)) {
Text(contact.shortFingerprint)
.font(.callout.monospaced())
}
} footer: {
Text(L10n.Approval.endpointId(deviceId: contact.endpointId))
.font(.caption2)
.textSelection(.enabled)
}
if !contact.canSend {
Section {
Label(
String(localized: L10n.Contacts.unreachableBody),
systemSymbol: .exclamationmarkTriangleFill
)
.font(.footnote)
}
}
Section {
Button(role: .destructive) {
isConfirmingForget = true
} label: {
Text(String(localized: L10n.Contacts.forget))
}
Button(role: .destructive) {
isConfirmingBlock = true
} label: {
Text(String(localized: L10n.Contacts.block))
}
}
.disabled(model.state.busyEndpoints.contains(endpointId))
}
}
.formStyle(.grouped)
.navigationTitle(Text(contact?.displayName ?? ""))
.onAppear { label = contact?.localLabel ?? "" }
.onDisappear { commitLabel() }
.confirmationDialog(
String(localized: L10n.Contacts.forget),
isPresented: $isConfirmingForget,
titleVisibility: .visible
) {
Button(String(localized: L10n.Contacts.forget), role: .destructive) {
Task { await model.forget(endpointId: endpointId) }
}
} message: {
Text(String(localized: L10n.Contacts.forgetBody))
}
.confirmationDialog(
String(localized: L10n.Contacts.block),
isPresented: $isConfirmingBlock,
titleVisibility: .visible
) {
Button(String(localized: L10n.Contacts.block), role: .destructive) {
Task { await model.block(endpointId: endpointId) }
}
} message: {
Text(String(localized: L10n.Contacts.unblockHint))
}
}
private func commitLabel() {
guard label != (contact?.localLabel ?? "") else { return }
Task { await model.setLabel(endpointId: endpointId, label: label) }
}
}