Files
vnidrop/apple/VniDrop/Features/Contacts/ContactsScreen.swift
cdricms 268fbf161d feat(apple): send files to a remembered device
Adds the Send files action to the device detail, routing the picked
selection through sendToContact.

Rather than a second picker path, sharePickedFiles now takes a
ShareDestination, so the macOS security-scoped access handling covers both
routes. A contact destination carries no access policy, matching the core's
rule that an offer share is never public.
2026-08-06 18:21:44 +02:00

294 lines
7.6 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
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 {
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) }
}
}