mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
Closes the loop: until now nothing in the UI could create a contact, so the list stayed empty unless the peer initiated. A completed receive names its sender and a completed delivery names its receiver, so both sides get the suggestion. Declining is persisted, or every later transfer with the same device would re-ask a question already answered; pairing deliberately afterwards clears that. The suggestion sheet ranks below the two other prompts, since nobody is waiting on the answer.
186 lines
5.6 KiB
Swift
186 lines
5.6 KiB
Swift
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
|
|
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.sheet(isPresented: $isPresented) {
|
|
// Ordered by who is waiting: a sender is blocked on an offer, a
|
|
// pairing request keeps until its consent window lapses, and a
|
|
// post-transfer suggestion has nobody waiting at all.
|
|
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())
|
|
} else if let suggestion = state.currentSuggestion {
|
|
// Lowest priority: nobody is waiting on this answer, it just
|
|
// follows a transfer that already finished.
|
|
SuggestionSheet(
|
|
suggestion: suggestion,
|
|
busy: state.busyEndpoints.contains(suggestion.endpointId),
|
|
onRespond: onSuggestionResponse
|
|
)
|
|
.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)
|
|
}
|
|
}
|
|
|
|
/// "You just transferred with this device. Let it reach you next time?"
|
|
private struct SuggestionSheet: View {
|
|
let suggestion: PairingSuggestion
|
|
let busy: Bool
|
|
let onRespond: (PairingSuggestion, Bool) -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemSymbol: .clockArrowCirclepath)
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(.tint)
|
|
.padding(.top, 12)
|
|
Text(String(localized: L10n.Pairing.allowTitle))
|
|
.font(.title2).fontWeight(.semibold)
|
|
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
|
|
.multilineTextAlignment(.center)
|
|
Text(String(localized: L10n.Pairing.allowBody))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
Spacer(minLength: 0)
|
|
HStack(spacing: 12) {
|
|
Button(role: .cancel) {
|
|
onRespond(suggestion, false)
|
|
} label: {
|
|
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
|
}
|
|
Button {
|
|
onRespond(suggestion, true)
|
|
} label: {
|
|
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.disabled(busy)
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|