mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 22:29:58 +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.
249 lines
7.6 KiB
Swift
249 lines
7.6 KiB
Swift
import SwiftUI
|
|
import SFSafeSymbols
|
|
|
|
/// Saved devices screen. A native `List` of saved devices and outstanding consent
|
|
/// requests; selecting one opens the per-device details surface. The global
|
|
/// targeted-transfer history is deliberately absent — transfers belong to a
|
|
/// device, and are reachable only through it.
|
|
struct SavedDevicesScreen: View {
|
|
@ObservedObject var model: SavedDevicesModel
|
|
let windowClass: WindowClass
|
|
|
|
/// Endpoint ID of the device whose details are open.
|
|
@State private var selectedPeerId: String?
|
|
|
|
private var state: SavedDevicesState { model.state }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if state.isLoading && state.isEmpty {
|
|
loadingState
|
|
} else if state.loadFailed && state.isEmpty {
|
|
failedState
|
|
} else if state.isEmpty {
|
|
emptyState
|
|
} else {
|
|
deviceList
|
|
}
|
|
}
|
|
// Title-only header once populated: explanatory copy belongs in the
|
|
// first-use empty state, not above a list the user already understands.
|
|
.navigationTitle(Text(String(localized: L10n.Saved.devicesListTitle)))
|
|
}
|
|
// The consent prompts are hosted at the app root, not here: they must be
|
|
// answerable from any tab, not only while this screen is showing.
|
|
.savedDeviceDetails(model: model, windowClass: windowClass, selectedPeerId: $selectedPeerId)
|
|
}
|
|
|
|
// MARK: - List
|
|
|
|
private var deviceList: some View {
|
|
List {
|
|
if !state.eligibilities.isEmpty || !state.pendingRelationships.isEmpty {
|
|
Section {
|
|
ForEach(state.pendingRelationships) { relationship in
|
|
PendingRelationshipRow(
|
|
relationship: relationship,
|
|
busy: state.busyPeerIds.contains(relationship.remoteEndpointId),
|
|
onAccept: { model.acceptIncoming(relationship.remoteEndpointId) },
|
|
onDecline: { model.declineIncoming(relationship.remoteEndpointId) }
|
|
)
|
|
}
|
|
ForEach(pendingEligibilities) { eligibility in
|
|
EligibilityRow(
|
|
eligibility: eligibility,
|
|
busy: state.busyPeerIds.contains(eligibility.peerEndpointId),
|
|
onRemember: { model.rememberEligible(eligibility.peerEndpointId) },
|
|
onDecline: { model.declineEligible(eligibility.peerEndpointId) }
|
|
)
|
|
}
|
|
} header: {
|
|
Text(String(localized: L10n.Saved.devicesAttentionTitle))
|
|
}
|
|
}
|
|
|
|
if !state.savedDevices.isEmpty {
|
|
Section {
|
|
ForEach(state.savedDevices) { device in
|
|
Button { selectedPeerId = device.endpointId } label: {
|
|
SavedDeviceRow(
|
|
device: device,
|
|
transferCount: state.transfers(for: device.endpointId).count,
|
|
busy: state.busyPeerIds.contains(device.endpointId)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu { deviceMenu(device) }
|
|
}
|
|
} header: {
|
|
Text(String(localized: L10n.Saved.devicesPendingTitle))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eligibilities whose peer already has a pending relationship are represented
|
|
/// by that relationship row instead, so the same device never appears twice.
|
|
private var pendingEligibilities: [PairingEligibilityModel] {
|
|
let pendingIds = Set(state.pendingRelationships.map(\.remoteEndpointId))
|
|
return state.eligibilities.filter { !pendingIds.contains($0.peerEndpointId) }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func deviceMenu(_ device: SavedDeviceModel) -> some View {
|
|
Button {
|
|
selectedPeerId = device.endpointId
|
|
} label: {
|
|
Label(String(localized: L10n.Saved.devicesSendAction), systemSymbol: .paperplane)
|
|
}
|
|
Button {
|
|
selectedPeerId = device.endpointId
|
|
model.openLabelEditor(device.endpointId)
|
|
} label: {
|
|
Label(String(localized: L10n.Saved.devicesLabelAction), systemSymbol: .pencil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Placeholder states
|
|
|
|
private var loadingState: some View {
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text(String(localized: L10n.Saved.devicesLoading))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
private var failedState: some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: L10n.Saved.devicesLoadFailed), systemSymbol: .exclamationmarkTriangleFill)
|
|
} actions: {
|
|
Button(String(localized: L10n.Button.retry), action: model.retry)
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
/// First-use state: the only place that explains what a saved device is.
|
|
private var emptyState: some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .laptopcomputerAndIphone)
|
|
} description: {
|
|
Text(String(localized: L10n.Saved.devicesEmpty))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
private struct SavedDeviceRow: View {
|
|
let device: SavedDeviceModel
|
|
let transferCount: Int
|
|
let busy: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
DeviceAvatar()
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(device.displayName)
|
|
.font(.body)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
EndpointIdLabel(endpointId: device.endpointId)
|
|
}
|
|
Spacer(minLength: 8)
|
|
if busy {
|
|
ProgressView().controlSize(.small)
|
|
} else if transferCount > 0 {
|
|
Text(verbatim: "\(transferCount)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
Image(systemSymbol: .chevronRight)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
/// A relationship awaiting consent. Incoming requests get accept/decline; an
|
|
/// outgoing request is informational until the peer answers.
|
|
private struct PendingRelationshipRow: View {
|
|
let relationship: DeviceRelationshipModel
|
|
let busy: Bool
|
|
let onAccept: () -> Void
|
|
let onDecline: () -> Void
|
|
|
|
private var isIncoming: Bool { relationship.state == .pendingIncoming }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 12) {
|
|
DeviceAvatar(symbol: .personBadgeClock, tint: .orange)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(String(localized: isIncoming
|
|
? L10n.Saved.devicesPendingIncoming
|
|
: L10n.Saved.devicesPendingOutgoing))
|
|
.font(.body)
|
|
.lineLimit(2)
|
|
EndpointIdLabel(endpointId: relationship.remoteEndpointId)
|
|
}
|
|
Spacer(minLength: 0)
|
|
if busy { ProgressView().controlSize(.small) }
|
|
}
|
|
if isIncoming {
|
|
HStack(spacing: 10) {
|
|
Button(String(localized: L10n.Saved.devicesAcceptPairingAction), action: onAccept)
|
|
.buttonStyle(.borderedProminent)
|
|
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.controlSize(.small)
|
|
.disabled(busy)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
/// A peer we completed a transfer with and may ask to pair. Naming it uses the
|
|
/// peer's untrusted hint — the only name available before the device is saved.
|
|
private struct EligibilityRow: View {
|
|
let eligibility: PairingEligibilityModel
|
|
let busy: Bool
|
|
let onRemember: () -> Void
|
|
let onDecline: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 12) {
|
|
DeviceAvatar(symbol: .checkmarkSealFill, tint: VniDropColors.brandPurple)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(eligibility.remoteDisplayName ?? String(localized: L10n.Saved.devicesUnnamed))
|
|
.font(.body)
|
|
.lineLimit(1)
|
|
Text(String(localized: L10n.Saved.devicesEligibilityTitle))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
EndpointIdLabel(endpointId: eligibility.peerEndpointId)
|
|
}
|
|
Spacer(minLength: 0)
|
|
if busy { ProgressView().controlSize(.small) }
|
|
}
|
|
HStack(spacing: 10) {
|
|
Button(String(localized: L10n.Saved.devicesRememberAction), action: onRemember)
|
|
.buttonStyle(.borderedProminent)
|
|
Button(String(localized: L10n.Saved.devicesDeclineAction), role: .destructive, action: onDecline)
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.controlSize(.small)
|
|
.disabled(busy)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|