Files
vnidrop/apple/VniDrop/Features/SavedDevices/SavedDevicePrompts.swift
cdricms 8bb1442338 feat(apple): saved devices and targeted transfers UI
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.
2026-08-13 19:42:37 +02:00

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)
}
}