mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
Adds the native SwiftUI Saved Devices experience on top of the production saved-device core, as a top-level destination in the iOS tab bar and the macOS sidebar. Core seam: - App-facing saved-device domain models mirroring core/SavedDeviceModels.kt, with lifecycle helpers (canReceive/canResume/canCancel/canDelete) so views never hand-roll state checks. - 21 gateway methods through CoreGateway/CoreRepository with UniFFI mapping. cancelTargetedTransfer, forgetSavedDevice and blockDevice run off the serial lane: each must reach the core while a targeted receive is blocking it. - Payload-free pairingChanged/targetedTransferChanged signals, dispatched before the numeric-transferId guard since saved-device events identify their subject by peer endpoint or a string transfer id. Experience: - Screen lists saved devices and outstanding consent requests only; the global targeted-transfer history stays out, reachable per device. - Details as a sheet with detents on compact layouts and a native inspector on macOS, owning Send, label, forget/block and that device's transfers. - Label editing is transactional: the draft and editor survive a failed write, conflicting actions are refused while saving, and the editor closes only after the core confirms. - Pairing and targeted-offer consent hosted at the app root, answerable from any tab and suppressed while a transfer approval is up. Dismissing a pairing prompt suppresses locally without consuming the single-use eligibility; dismissing an offer declines it, since an unanswered offer holds a slot in the core's bounded per-sender queue. - Targeted send reuses the invitation composer's affordances with file, folder, rename, replace and cleanup parity. Picker copies are released on replace/remove/clear/cancel and after a successful create, but kept after a failure so retry does not require re-picking. - Notifications for pairing requests and offers (withdrawn once answered) and for terminal targeted transfers. Wording follows direction: on the sending device the peer finished receiving, not us. Localization: - Widens 52 saved-device keys from kmp-only to both platforms. - Five keys carried a literal %1$s with no declared args, which Compose renders positionally but the Apple generator emits as a plain constant, leaking the placeholder into the UI. They now use named args; Compose output is byte-identical. - Adds targeted_offer_title/body. Reusing the invitation approval copy stated the roles backwards, announcing the sender as the receiver. Also surfaces core startup failures: the startup overlay is drawn above the snackbar host, so a failed initialize() was indistinguishable from an app that never finished loading. AppModel now keeps the reason, logs it, and the overlay shows it with a retry, plus the technical detail in DEBUG builds. Send and receive between two devices is verified only partially; a missing endpoint-identity credential currently blocks startup on the test device.
128 lines
4.6 KiB
Swift
128 lines
4.6 KiB
Swift
import SwiftUI
|
|
import SFSafeSymbols
|
|
|
|
/// Consent prompts for the saved-device domain: the pairing question and the
|
|
/// targeted-offer approval. Both are blocking decisions, so each is a native
|
|
/// alert rather than an inline banner that could be scrolled past.
|
|
///
|
|
/// These live outside Experimental Settings — saving a device and approving a
|
|
/// targeted transfer are top-level product decisions.
|
|
|
|
extension View {
|
|
/// Hosts both saved-device consent prompts. `suppressed` is set while the
|
|
/// transfer-approval modal is up, so the user is never answering two blocking
|
|
/// decisions at once — the approval belongs to a transfer this device is
|
|
/// sending, these belong to a device asking to reach it.
|
|
func savedDevicePrompts(model: SavedDevicesModel, suppressed: Bool) -> some View {
|
|
modifier(PairingPromptHost(model: model, suppressed: suppressed))
|
|
.modifier(TargetedOfferPromptHost(model: model, suppressed: suppressed))
|
|
}
|
|
}
|
|
|
|
private struct PairingPromptHost: ViewModifier {
|
|
@ObservedObject var model: SavedDevicesModel
|
|
let suppressed: Bool
|
|
|
|
private var prompt: PairingPrompt? {
|
|
suppressed ? nil : model.state.pairingPrompt.prompt
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
content.alert(
|
|
Text(String(localized: title)),
|
|
isPresented: Binding(
|
|
get: { prompt != nil },
|
|
// A swipe/escape dismissal is not an answer: suppress locally
|
|
// without consuming the core's single-use eligibility. The
|
|
// `suppressed` guard matters — hiding the alert for the approval
|
|
// modal also fires this setter, and must not count as a dismissal.
|
|
set: { if !$0, !suppressed { model.dismissPairingPrompt() } }
|
|
),
|
|
presenting: prompt
|
|
) { prompt in
|
|
Button(String(localized: acceptLabel(prompt)), action: model.acceptPairingPrompt)
|
|
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: model.declinePairingPrompt)
|
|
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissPairingPrompt)
|
|
} message: { prompt in
|
|
messageText(prompt)
|
|
}
|
|
}
|
|
|
|
private var title: String.LocalizationValue {
|
|
switch prompt {
|
|
case .incomingRequest: return L10n.Pairing.requestTitle
|
|
case .eligibility, nil: return L10n.Pairing.allowTitle
|
|
}
|
|
}
|
|
|
|
/// The incoming-request copy names the asking device; the eligibility copy is
|
|
/// a fixed explanation of what remembering allows.
|
|
@ViewBuilder
|
|
private func messageText(_ prompt: PairingPrompt) -> some View {
|
|
switch prompt {
|
|
case .incomingRequest:
|
|
Text(L10n.Pairing.requestBody(device: deviceName(prompt)))
|
|
case .eligibility:
|
|
Text(String(localized: L10n.Pairing.allowBody))
|
|
}
|
|
}
|
|
|
|
private func acceptLabel(_ prompt: PairingPrompt) -> String.LocalizationValue {
|
|
switch prompt {
|
|
case .incomingRequest: return L10n.Pairing.accept
|
|
case .eligibility: return L10n.Saved.devicesRememberAction
|
|
}
|
|
}
|
|
|
|
/// Falls back to a neutral placeholder: an unsaved peer's name is an untrusted
|
|
/// hint, and there may be none at all.
|
|
private func deviceName(_ prompt: PairingPrompt) -> String {
|
|
prompt.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed)
|
|
}
|
|
}
|
|
|
|
private struct TargetedOfferPromptHost: ViewModifier {
|
|
@ObservedObject var model: SavedDevicesModel
|
|
let suppressed: Bool
|
|
|
|
private var offer: PendingTargetedOfferModel? {
|
|
suppressed ? nil : model.state.targetedOffers.current
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
content.alert(
|
|
// Not the invitation-approval copy: there the remote device asks to
|
|
// *receive* from us, here it is offering to *send* to us.
|
|
Text(String(localized: L10n.Targeted.offerTitle)),
|
|
isPresented: Binding(
|
|
get: { offer != nil },
|
|
// Dismissal declines: an unanswered offer would otherwise hold a
|
|
// slot in the core's bounded per-sender queue. Never while
|
|
// `suppressed`, though — being hidden behind the approval modal
|
|
// must not silently decline the sender.
|
|
set: { if !$0, !suppressed, let offer { model.declineTargetedOffer(offer.transferId) } }
|
|
),
|
|
presenting: offer
|
|
) { offer in
|
|
Button(String(localized: L10n.Button.approve)) {
|
|
model.acceptTargetedOffer(offer.transferId)
|
|
}
|
|
Button(String(localized: L10n.Button.refuse), role: .destructive) {
|
|
model.declineTargetedOffer(offer.transferId)
|
|
}
|
|
} message: { offer in
|
|
Text(L10n.Targeted.offerBody(
|
|
device: senderName,
|
|
transferName: offer.transferName
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Only a *saved* sender has a name we can vouch for; anything else stays
|
|
/// generic rather than rendering a peer-supplied string as verified.
|
|
private var senderName: String {
|
|
model.state.targetedOffers.currentSenderDisplayName
|
|
?? String(localized: L10n.Approval.nearbyDevice)
|
|
}
|
|
}
|