mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
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:
@@ -12,6 +12,7 @@ final class AppGraph: ObservableObject {
|
|||||||
let preferencesRepository: AppPreferencesRepository
|
let preferencesRepository: AppPreferencesRepository
|
||||||
let filePreviewRepository: FilePreviewRepository
|
let filePreviewRepository: FilePreviewRepository
|
||||||
let approvalCoordinator: ApprovalCoordinator
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
|
let contactsModel: ContactsModel
|
||||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
let backgroundActivity: BackgroundActivityController
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ final class AppGraph: ObservableObject {
|
|||||||
themeMode: .system
|
themeMode: .system
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.contactsModel = ContactsModel(
|
||||||
|
repository: coreRepository,
|
||||||
|
messages: messages,
|
||||||
|
preferences: preferencesRepository
|
||||||
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: coreRepository,
|
repository: coreRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ struct RootView: View {
|
|||||||
approvals: graph.approvalCoordinator,
|
approvals: graph.approvalCoordinator,
|
||||||
sendModel: sendModel
|
sendModel: sendModel
|
||||||
)
|
)
|
||||||
|
ContactPromptLayer(
|
||||||
|
contacts: graph.contactsModel,
|
||||||
|
receiveModel: receiveModel,
|
||||||
|
approvals: graph.approvalCoordinator
|
||||||
|
)
|
||||||
// Top-most so the toast is never covered by the approval overlay's
|
// Top-most so the toast is never covered by the approval overlay's
|
||||||
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||||
SnackbarHost(controller: graph.messages)
|
SnackbarHost(controller: graph.messages)
|
||||||
@@ -167,7 +172,8 @@ struct RootView: View {
|
|||||||
switch destination {
|
switch destination {
|
||||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||||
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
case .settings:
|
||||||
|
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,3 +289,49 @@ import UIKit
|
|||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
|
||||||
|
///
|
||||||
|
/// Separate from the approval layer because the two never compete: an approval
|
||||||
|
/// belongs to a transfer this device is sending, and these belong to a device
|
||||||
|
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||||
|
/// never answering two modals at once.
|
||||||
|
private struct ContactPromptLayer: View {
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
|
let receiveModel: ReceiveModel
|
||||||
|
@ObservedObject var approvals: ApprovalCoordinator
|
||||||
|
|
||||||
|
@State private var showPrompt = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ContactPromptHost(
|
||||||
|
isPresented: $showPrompt,
|
||||||
|
state: contacts.state,
|
||||||
|
onPairingResponse: { endpointId, accepted in
|
||||||
|
Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) }
|
||||||
|
},
|
||||||
|
onOfferResponse: { offerId, accepted in
|
||||||
|
Task {
|
||||||
|
// The ticket is released only on acceptance; the receive then
|
||||||
|
// runs through the ordinary path so the platform picks the
|
||||||
|
// destination.
|
||||||
|
if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) {
|
||||||
|
receiveModel.receiveOffered(ticket: ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onChange(of: promptKey) { _, key in
|
||||||
|
showPrompt = key != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One identity for "is there something to answer", so an offer replacing a
|
||||||
|
/// pairing prompt re-presents rather than silently swapping content.
|
||||||
|
private var promptKey: String? {
|
||||||
|
guard approvals.state.current == nil else { return nil }
|
||||||
|
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
||||||
|
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
133
apple/VniDrop/Features/Contacts/ContactPrompts.swift
Normal file
133
apple/VniDrop/Features/Contacts/ContactPrompts.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
280
apple/VniDrop/Features/Contacts/ContactsScreen.swift
Normal file
280
apple/VniDrop/Features/Contacts/ContactsScreen.swift
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ enum ReceiveMethod {
|
|||||||
case invitationFile
|
case invitationFile
|
||||||
case qrCode
|
case qrCode
|
||||||
case nfc
|
case nfc
|
||||||
|
/// Pushed by a remembered device and already accepted by the user, so no
|
||||||
|
/// invitation was acquired by hand.
|
||||||
|
case offer
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReceiveHistoryDeleteTarget: Equatable {
|
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||||
@@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Receive a transfer the user has already accepted in the offer prompt.
|
||||||
|
///
|
||||||
|
/// The consent happened in that prompt, so this does not ask again: it
|
||||||
|
/// inspects the ticket and starts, falling back to the ordinary review sheet
|
||||||
|
/// only when the destination is not usable and the user has to fix it.
|
||||||
|
func receiveOffered(ticket: String) {
|
||||||
|
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||||
|
state.ticket = trimmed
|
||||||
|
state.method = .offer
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = true
|
||||||
|
Task {
|
||||||
|
switch await repository.inspectTicket(trimmed) {
|
||||||
|
case .success(let inspection):
|
||||||
|
state.inspection = inspection
|
||||||
|
state.isInspecting = false
|
||||||
|
if state.canReceive(coreInitialized: coreState.isInitialized) {
|
||||||
|
receive()
|
||||||
|
} else {
|
||||||
|
// Usually a missing or unwritable destination: show the review
|
||||||
|
// sheet so the user can point it somewhere valid.
|
||||||
|
state.isAcquisitionOpen = true
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
state.ticket = ""
|
||||||
|
state.method = nil
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func receive() {
|
func receive() {
|
||||||
let current = state
|
let current = state
|
||||||
guard let folder = current.receiveFolder else { return }
|
guard let folder = current.receiveFolder else { return }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ enum SettingsSection: Hashable {
|
|||||||
case appearance
|
case appearance
|
||||||
case notifications
|
case notifications
|
||||||
case network
|
case network
|
||||||
|
case contacts
|
||||||
case storage
|
case storage
|
||||||
case about
|
case about
|
||||||
case bugReport
|
case bugReport
|
||||||
@@ -19,6 +20,7 @@ enum SettingsSection: Hashable {
|
|||||||
case .appearance: return L10n.Appearance.title
|
case .appearance: return L10n.Appearance.title
|
||||||
case .notifications: return L10n.Notifications.title
|
case .notifications: return L10n.Notifications.title
|
||||||
case .network: return L10n.Settings.networkTitle
|
case .network: return L10n.Settings.networkTitle
|
||||||
|
case .contacts: return L10n.Contacts.title
|
||||||
case .storage: return L10n.Storage.title
|
case .storage: return L10n.Storage.title
|
||||||
case .about: return L10n.About.title
|
case .about: return L10n.About.title
|
||||||
case .bugReport: return L10n.About.bugReport
|
case .bugReport: return L10n.About.bugReport
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
|||||||
/// navigation. The model stays the source of truth via a derived path binding.
|
/// navigation. The model stays the source of truth via a derived path binding.
|
||||||
struct SettingsScreen: View {
|
struct SettingsScreen: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
@State private var showBugReport = false
|
@State private var showBugReport = false
|
||||||
|
|
||||||
@@ -54,6 +55,15 @@ struct SettingsScreen: View {
|
|||||||
NavigationLink(value: SettingsSection.storage) {
|
NavigationLink(value: SettingsSection.storage) {
|
||||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||||
}
|
}
|
||||||
|
NavigationLink(value: SettingsSection.contacts) {
|
||||||
|
SettingsRow(
|
||||||
|
icon: .laptopcomputerAndIphone,
|
||||||
|
title: String(localized: L10n.Contacts.title),
|
||||||
|
value: contacts.state.contacts.isEmpty
|
||||||
|
? nil
|
||||||
|
: String(contacts.state.contacts.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||||
NavigationLink(value: SettingsSection.network) {
|
NavigationLink(value: SettingsSection.network) {
|
||||||
@@ -80,6 +90,17 @@ struct SettingsScreen: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||||
|
// Contacts brings its own Form and push destination, so it is not wrapped
|
||||||
|
// in the shared section chrome.
|
||||||
|
if section == .contacts {
|
||||||
|
ContactsScreen(model: contacts)
|
||||||
|
} else {
|
||||||
|
settingsSectionForm(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func settingsSectionForm(_ section: SettingsSection) -> some View {
|
||||||
let content = Form {
|
let content = Form {
|
||||||
SettingsSectionContent(model: model, section: section)
|
SettingsSectionContent(model: model, section: section)
|
||||||
}
|
}
|
||||||
@@ -130,6 +151,9 @@ private struct SettingsSectionContent: View {
|
|||||||
NetworkSettings(model: model)
|
NetworkSettings(model: model)
|
||||||
case .storage:
|
case .storage:
|
||||||
StorageSettings(model: model)
|
StorageSettings(model: model)
|
||||||
|
case .contacts:
|
||||||
|
// Rendered by SettingsScreen itself, which owns the contacts model.
|
||||||
|
EmptyView()
|
||||||
case .about:
|
case .about:
|
||||||
AboutSettings(model: model)
|
AboutSettings(model: model)
|
||||||
case .bugReport:
|
case .bugReport:
|
||||||
|
|||||||
Reference in New Issue
Block a user