mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
Adds the opt-in foreground check and an explicit Check now, the waiting-to- be-delivered list on the sender side, and honest reporting when a send could not be delivered: a closed app is a delay, not a success nobody received. The setting is off by default and its footer states that checking reveals app-open times to remembered devices, since that is the reason it is a setting at all. Records in the design doc that this shipped as one global toggle rather than the per-contact opt-in originally specified.
356 lines
9.3 KiB
Swift
356 lines
9.3 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(
|
|
enabled: model.state.checkForOffersOnOpen,
|
|
isChecking: model.state.isCheckingForOffers,
|
|
onToggle: model.setCheckForOffersOnOpen,
|
|
onCheckNow: {
|
|
Task {
|
|
let collected = await model.collectWaitingOffers()
|
|
if collected == 0 { onNothingWaiting() }
|
|
}
|
|
}
|
|
)
|
|
|
|
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 CollectOffersSection: View {
|
|
let enabled: Bool
|
|
let isChecking: Bool
|
|
let onToggle: (Bool) -> Void
|
|
let onCheckNow: () -> Void
|
|
|
|
var body: some View {
|
|
Section {
|
|
Toggle(
|
|
String(localized: L10n.Contacts.checkOnOpen),
|
|
isOn: Binding(get: { enabled }, set: onToggle)
|
|
)
|
|
Button(action: onCheckNow) {
|
|
HStack {
|
|
Text(String(localized: L10n.Contacts.checkNow))
|
|
if isChecking {
|
|
Spacer()
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.disabled(isChecking)
|
|
} 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 {
|
|
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) }
|
|
}
|
|
}
|