mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
feat(apple): send a transfer to a device from the share panel
Send to a device now sits alongside the QR code, NFC, and export actions, since an offer is another way to deliver the same invitation. Picking a device pushes the existing transfer rather than re-sharing the files. The picker lists only devices holding a live grant, so nothing offered there can fail on tap, and it distinguishes accepted from waiting for that device to open the app. Also fixes the deprecated SF Symbol and the two Sendable warnings introduced with the contacts screen: the sections now talk to the model directly rather than storing view callbacks that a Binding setter has to convert.
This commit is contained in:
@@ -110,7 +110,7 @@ private struct PairingSheet: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemSymbol: .laptopcomputerAndIphone)
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
|
||||
@@ -345,6 +345,32 @@ final class ContactsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an existing transfer to a remembered device.
|
||||
///
|
||||
/// Returns whether it landed, so the caller can distinguish "accepted" from
|
||||
/// "waiting for that device to open the app".
|
||||
@discardableResult
|
||||
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
|
||||
state.busyEndpoints.insert(contact.endpointId)
|
||||
defer { state.busyEndpoints.remove(contact.endpointId) }
|
||||
|
||||
switch await repository.offerTransferToContact(
|
||||
transferId: transferId,
|
||||
endpointId: contact.endpointId
|
||||
) {
|
||||
case .success(let outcome):
|
||||
let text: UiText = outcome.delivered
|
||||
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
|
||||
: .resource(L10n.Contacts.offerHeld)
|
||||
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||
await refresh()
|
||||
return outcome.delivered
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Management
|
||||
|
||||
func setLabel(endpointId: String, label: String) async {
|
||||
|
||||
@@ -63,22 +63,9 @@ struct ContactsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
CollectOffersSection(
|
||||
enabled: model.state.checkForOffersOnOpen,
|
||||
isChecking: model.state.isCheckingForOffers,
|
||||
onToggle: model.setCheckForOffersOnOpen,
|
||||
onCheckNow: {
|
||||
Task {
|
||||
let collected = await model.collectWaitingOffers()
|
||||
if collected == 0 { onNothingWaiting() }
|
||||
}
|
||||
}
|
||||
)
|
||||
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
|
||||
|
||||
GrantLifetimeSection(
|
||||
selection: model.state.grantLifetime,
|
||||
onSelect: model.setGrantLifetime
|
||||
)
|
||||
GrantLifetimeSection(model: model)
|
||||
|
||||
if !model.state.contacts.isEmpty {
|
||||
Section {
|
||||
@@ -98,7 +85,7 @@ struct ContactsScreen: View {
|
||||
private struct ContactsEmptyState: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemSymbol: .laptopcomputerAndIphone)
|
||||
Image(systemSymbol: .macbookAndIphone)
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tint)
|
||||
Text(String(localized: L10n.Contacts.emptyTitle))
|
||||
@@ -162,27 +149,33 @@ private struct BlockedRow: View {
|
||||
}
|
||||
|
||||
private struct CollectOffersSection: View {
|
||||
let enabled: Bool
|
||||
let isChecking: Bool
|
||||
let onToggle: (Bool) -> Void
|
||||
let onCheckNow: () -> Void
|
||||
@ObservedObject var model: ContactsModel
|
||||
let onNothingWaiting: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(
|
||||
String(localized: L10n.Contacts.checkOnOpen),
|
||||
isOn: Binding(get: { enabled }, set: onToggle)
|
||||
isOn: Binding(
|
||||
get: { model.state.checkForOffersOnOpen },
|
||||
set: { model.setCheckForOffersOnOpen($0) }
|
||||
)
|
||||
)
|
||||
Button(action: onCheckNow) {
|
||||
Button {
|
||||
Task {
|
||||
let collected = await model.collectWaitingOffers()
|
||||
if collected == 0 { onNothingWaiting() }
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(String(localized: L10n.Contacts.checkNow))
|
||||
if isChecking {
|
||||
if model.state.isCheckingForOffers {
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isChecking)
|
||||
.disabled(model.state.isCheckingForOffers)
|
||||
} footer: {
|
||||
// The privacy cost is the point of the setting, so it is stated
|
||||
// where the switch is, not buried elsewhere.
|
||||
@@ -192,14 +185,16 @@ private struct CollectOffersSection: View {
|
||||
}
|
||||
|
||||
private struct GrantLifetimeSection: View {
|
||||
let selection: GrantLifetimeOption
|
||||
let onSelect: (GrantLifetimeOption) -> Void
|
||||
@ObservedObject var model: ContactsModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: L10n.Contacts.grantLifetimeTitle),
|
||||
selection: Binding(get: { selection }, set: onSelect)
|
||||
selection: Binding(
|
||||
get: { model.state.grantLifetime },
|
||||
set: { model.setGrantLifetime($0) }
|
||||
)
|
||||
) {
|
||||
ForEach(GrantLifetimeOption.allCases) { option in
|
||||
Text(Self.label(option)).tag(option)
|
||||
|
||||
71
apple/VniDrop/Features/Contacts/DevicePickerSheet.swift
Normal file
71
apple/VniDrop/Features/Contacts/DevicePickerSheet.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// Picks a remembered device to send an existing transfer to.
|
||||
///
|
||||
/// Offered next to the QR code as another way to deliver the same invitation,
|
||||
/// not as a second share of the same files.
|
||||
struct DevicePickerSheet: View {
|
||||
@ObservedObject var model: ContactsModel
|
||||
let transferId: UInt64
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Only devices holding a live grant: the rest cannot be reached until they
|
||||
/// are paired again, so offering them here would fail on tap.
|
||||
private var reachable: [DeviceContact] {
|
||||
model.state.contacts.filter(\.canSend)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if reachable.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
String(localized: L10n.Contacts.pickDeviceTitle),
|
||||
systemSymbol: .macbookAndIphone
|
||||
)
|
||||
} description: {
|
||||
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
|
||||
}
|
||||
} else {
|
||||
List(reachable) { contact in
|
||||
Button {
|
||||
Task {
|
||||
await model.offerTransfer(transferId: transferId, to: contact)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contact.displayName)
|
||||
Text(contact.shortFingerprint)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if model.state.busyEndpoints.contains(contact.endpointId) {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!model.state.busyEndpoints.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await model.refresh() }
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380, minHeight: 320)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user