mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
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.
This commit is contained in:
248
apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift
Normal file
248
apple/VniDrop/Features/SavedDevices/SavedDevicesScreen.swift
Normal file
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user