Files
vnidrop/apple/VniDrop/Features/Contacts/ContactsScreen.swift
cdricms 3441280599 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.
2026-08-07 10:24:05 +02:00

351 lines
9.2 KiB
Swift

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
/// Reports an empty result, which a silent refresh cannot convey.
let onNothingWaiting: () -> Void
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.heldOffers.isEmpty {
Section(String(localized: L10n.Contacts.waitingTitle)) {
ForEach(model.state.heldOffers) { offer in
VStack(alignment: .leading, spacing: 2) {
Text(offer.transferName)
Text(String(offer.endpointId.prefix(16)))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
Text(String(localized: L10n.Contacts.waitingHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
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)
}
}
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
GrantLifetimeSection(model: model)
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: .macbookAndIphone)
.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 CollectOffersSection: View {
@ObservedObject var model: ContactsModel
let onNothingWaiting: () -> Void
var body: some View {
Section {
Toggle(
String(localized: L10n.Contacts.checkOnOpen),
isOn: Binding(
get: { model.state.checkForOffersOnOpen },
set: { model.setCheckForOffersOnOpen($0) }
)
)
Button {
Task {
let collected = await model.collectWaitingOffers()
if collected == 0 { onNothingWaiting() }
}
} label: {
HStack {
Text(String(localized: L10n.Contacts.checkNow))
if model.state.isCheckingForOffers {
Spacer()
ProgressView().controlSize(.small)
}
}
}
.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.
Text(String(localized: L10n.Contacts.checkOnOpenHint))
}
}
}
private struct GrantLifetimeSection: View {
@ObservedObject var model: ContactsModel
var body: some View {
Section {
Picker(
String(localized: L10n.Contacts.grantLifetimeTitle),
selection: Binding(
get: { model.state.grantLifetime },
set: { model.setGrantLifetime($0) }
)
) {
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 {
Button {
model.chooseFilesToSend(to: endpointId)
} label: {
Label(
String(localized: L10n.Contacts.sendTo),
systemSymbol: .paperplane
)
}
.disabled(model.state.busyEndpoints.contains(endpointId))
}
} else {
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 ?? ""))
.contactSendPickers(model: model)
.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) }
}
}