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:
216
apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift
Normal file
216
apple/VniDrop/Features/SavedDevices/TargetedSendComposer.swift
Normal file
@@ -0,0 +1,216 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Source composition for a targeted send. Deliberately mirrors the invitation
|
||||
/// composer's file/folder/rename/replace/clear affordances — the two domains stay
|
||||
/// distinct after creation, but choosing what to send works the same way.
|
||||
struct TargetedSendComposer: View {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
let deviceName: String
|
||||
|
||||
private var state: SavedDevicesState { model.state }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if state.sendFiles.isEmpty {
|
||||
chooseStep
|
||||
} else {
|
||||
reviewStep
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.savedDeviceSendPickers(model: model)
|
||||
}
|
||||
|
||||
private var chooseStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(String(localized: L10n.Send.chooseFileTitle))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text(recipientLine)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(spacing: 14) {
|
||||
Image(systemSymbol: .doc)
|
||||
.font(.system(size: 30))
|
||||
.foregroundStyle(.tint)
|
||||
PrimaryButton(
|
||||
title: String(localized: L10n.Button.chooseFiles),
|
||||
action: model.selectSendFiles
|
||||
)
|
||||
.fixedSize()
|
||||
QuietButton(
|
||||
title: String(localized: L10n.Button.chooseFolder),
|
||||
action: model.selectSendFolder
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(28)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
private var reviewStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(String(localized: L10n.Send.reviewTitle))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text(recipientLine)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ForEach(state.sendFiles) { file in
|
||||
TargetedSourceCard(
|
||||
file: file,
|
||||
canRemove: state.sendFiles.count > 1 && !state.isCreatingSend,
|
||||
onRemove: { model.removeSendFile(file.value) }
|
||||
)
|
||||
}
|
||||
|
||||
Field(
|
||||
label: String(localized: L10n.Field.transferName),
|
||||
value: Binding(
|
||||
get: { state.sendTransferName },
|
||||
set: { model.setSendTransferName($0) }
|
||||
),
|
||||
enabled: !state.isCreatingSend
|
||||
)
|
||||
|
||||
// Every targeted transfer still needs the receiver to approve it;
|
||||
// saying so here sets the right expectation before sending.
|
||||
Label(
|
||||
String(localized: L10n.Send.accessApprovalDescription),
|
||||
systemSymbol: .checkmarkShield
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
private var actions: some View {
|
||||
VStack(spacing: 10) {
|
||||
PrimaryButton(
|
||||
title: String(localized: L10n.Saved.devicesSendAction),
|
||||
action: model.createTargetedTransfer,
|
||||
enabled: state.canCreateTargetedTransfer
|
||||
)
|
||||
HStack(spacing: 10) {
|
||||
sourceButton(
|
||||
title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectSendFiles
|
||||
)
|
||||
sourceButton(
|
||||
title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectSendFolder
|
||||
)
|
||||
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSendFiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceButton(
|
||||
title: String.LocalizationValue,
|
||||
symbol: SFSymbol,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Label(String(localized: title), systemSymbol: symbol)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 20)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
.tint(.secondary)
|
||||
.disabled(state.isCreatingSend)
|
||||
}
|
||||
|
||||
private var recipientLine: String {
|
||||
L10n.Saved.devicesTransferDirectionOutgoing(device: deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TargetedSourceCard: View {
|
||||
let file: PickedShareFile
|
||||
let canRemove: Bool
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemSymbol: file.isDirectory ? .folder : .doc)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(file.displayName).lineLimit(1)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if canRemove {
|
||||
Button(role: .destructive, action: onRemove) {
|
||||
Image(systemSymbol: .trash)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
|
||||
if let size = file.sizeBytes { return formatBytes(size) }
|
||||
return String(localized: L10n.Send.fileSizeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
/// File/folder picker for targeted send. Attached to the composer so it presents
|
||||
/// from the composer's own sheet rather than the already-presenting root — the
|
||||
/// same constraint `SendPickers` documents.
|
||||
private struct SavedDeviceSendPickers: ViewModifier {
|
||||
@ObservedObject var model: SavedDevicesModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// One .fileImporter switched between files and folders: stacking two on a
|
||||
// single view silently breaks, with the second shadowing the first.
|
||||
content.fileImporter(
|
||||
isPresented: Binding(
|
||||
get: { model.pendingFilePick || model.pendingFolderPick },
|
||||
set: { presented in
|
||||
if !presented {
|
||||
model.pendingFilePick = false
|
||||
model.pendingFolderPick = false
|
||||
}
|
||||
}
|
||||
),
|
||||
allowedContentTypes: model.pendingFolderPick ? [.folder] : [.item],
|
||||
allowsMultipleSelection: !model.pendingFolderPick
|
||||
) { result in
|
||||
let isDirectory = model.pendingFolderPick
|
||||
model.pendingFilePick = false
|
||||
model.pendingFolderPick = false
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
|
||||
if files.isEmpty {
|
||||
model.onSendFilePickFailed("")
|
||||
} else {
|
||||
model.onSendFilesPicked(files)
|
||||
}
|
||||
case .failure(let error):
|
||||
if !error.isUserCancellation { model.onSendFilePickFailed(error.technicalDetail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func savedDeviceSendPickers(model: SavedDevicesModel) -> some View {
|
||||
modifier(SavedDeviceSendPickers(model: model))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user