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:
@@ -374,6 +374,46 @@ final class ContactsModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
|
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Offering an existing transfer reuses it rather than creating another.
|
||||||
|
func testOfferingAnExistingTransferReportsAcceptance() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offerTransferResult = .success(
|
||||||
|
ContactSendOutcome(
|
||||||
|
share: Share(
|
||||||
|
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||||
|
contentHash: "h", fileCount: 1, totalSize: 2
|
||||||
|
),
|
||||||
|
delivered: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||||
|
|
||||||
|
XCTAssertTrue(delivered)
|
||||||
|
XCTAssertEqual(gateway.offeredTransfers.map(\.transferId), [7])
|
||||||
|
XCTAssertEqual(gateway.offeredTransfers.map(\.endpointId), ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offer to a closed device is reported as waiting, not accepted.
|
||||||
|
func testOfferingToAClosedDeviceReportsItAsWaiting() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offerTransferResult = .success(
|
||||||
|
ContactSendOutcome(
|
||||||
|
share: Share(
|
||||||
|
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||||
|
contentHash: "h", fileCount: 1, totalSize: 2
|
||||||
|
),
|
||||||
|
delivered: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||||
|
|
||||||
|
XCTAssertFalse(delivered)
|
||||||
|
}
|
||||||
|
|
||||||
func testUnreachableContactIsSurfacedForRepairing() async {
|
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||||
let gateway = FakeCoreGateway()
|
let gateway = FakeCoreGateway()
|
||||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
sentToContacts.append(endpointId)
|
sentToContacts.append(endpointId)
|
||||||
return sendToContactResult
|
return sendToContactResult
|
||||||
}
|
}
|
||||||
|
private(set) var offeredTransfers: [(transferId: UInt64, endpointId: String)] = []
|
||||||
|
var offerTransferResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||||
|
|
||||||
|
func offerTransferToContact(
|
||||||
|
transferId: UInt64,
|
||||||
|
endpointId: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
offeredTransfers.append((transferId, endpointId))
|
||||||
|
return offerTransferResult
|
||||||
|
}
|
||||||
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
|
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
|
||||||
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||||
pollCount += 1
|
pollCount += 1
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ struct RootView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||||
switch destination {
|
switch destination {
|
||||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||||
case .settings:
|
case .settings:
|
||||||
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ protocol CoreGateway: AnyObject {
|
|||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String
|
senderName: String
|
||||||
) async -> Result<ContactSendOutcome, Error>
|
) async -> Result<ContactSendOutcome, Error>
|
||||||
|
/// Offer an existing share to a remembered device, alongside its QR code.
|
||||||
|
func offerTransferToContact(
|
||||||
|
transferId: UInt64,
|
||||||
|
endpointId: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error>
|
||||||
/// Transfers this device is holding for contacts that were not running.
|
/// Transfers this device is holding for contacts that were not running.
|
||||||
func heldOffers() async -> Result<[HeldOfferModel], Error>
|
func heldOffers() async -> Result<[HeldOfferModel], Error>
|
||||||
/// Ask remembered devices whether they hold anything for this one.
|
/// Ask remembered devices whether they hold anything for this one.
|
||||||
|
|||||||
@@ -359,6 +359,18 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func offerTransferToContact(
|
||||||
|
transferId: UInt64,
|
||||||
|
endpointId: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
await runCore {
|
||||||
|
let result = try self.requireCore().offerTransferToContact(
|
||||||
|
transferId: transferId, endpointId: endpointId
|
||||||
|
)
|
||||||
|
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func heldOffers() async -> Result<[HeldOfferModel], Error> {
|
func heldOffers() async -> Result<[HeldOfferModel], Error> {
|
||||||
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
|
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ private struct PairingSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemSymbol: .laptopcomputerAndIphone)
|
Image(systemSymbol: .macbookAndIphone)
|
||||||
.font(.system(size: 44))
|
.font(.system(size: 44))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.padding(.top, 12)
|
.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
|
// MARK: - Management
|
||||||
|
|
||||||
func setLabel(endpointId: String, label: String) async {
|
func setLabel(endpointId: String, label: String) async {
|
||||||
|
|||||||
@@ -63,22 +63,9 @@ struct ContactsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectOffersSection(
|
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
|
||||||
enabled: model.state.checkForOffersOnOpen,
|
|
||||||
isChecking: model.state.isCheckingForOffers,
|
|
||||||
onToggle: model.setCheckForOffersOnOpen,
|
|
||||||
onCheckNow: {
|
|
||||||
Task {
|
|
||||||
let collected = await model.collectWaitingOffers()
|
|
||||||
if collected == 0 { onNothingWaiting() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
GrantLifetimeSection(
|
GrantLifetimeSection(model: model)
|
||||||
selection: model.state.grantLifetime,
|
|
||||||
onSelect: model.setGrantLifetime
|
|
||||||
)
|
|
||||||
|
|
||||||
if !model.state.contacts.isEmpty {
|
if !model.state.contacts.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
@@ -98,7 +85,7 @@ struct ContactsScreen: View {
|
|||||||
private struct ContactsEmptyState: View {
|
private struct ContactsEmptyState: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemSymbol: .laptopcomputerAndIphone)
|
Image(systemSymbol: .macbookAndIphone)
|
||||||
.font(.system(size: 32))
|
.font(.system(size: 32))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
Text(String(localized: L10n.Contacts.emptyTitle))
|
Text(String(localized: L10n.Contacts.emptyTitle))
|
||||||
@@ -162,27 +149,33 @@ private struct BlockedRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct CollectOffersSection: View {
|
private struct CollectOffersSection: View {
|
||||||
let enabled: Bool
|
@ObservedObject var model: ContactsModel
|
||||||
let isChecking: Bool
|
let onNothingWaiting: () -> Void
|
||||||
let onToggle: (Bool) -> Void
|
|
||||||
let onCheckNow: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
Toggle(
|
Toggle(
|
||||||
String(localized: L10n.Contacts.checkOnOpen),
|
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 {
|
HStack {
|
||||||
Text(String(localized: L10n.Contacts.checkNow))
|
Text(String(localized: L10n.Contacts.checkNow))
|
||||||
if isChecking {
|
if model.state.isCheckingForOffers {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isChecking)
|
.disabled(model.state.isCheckingForOffers)
|
||||||
} footer: {
|
} footer: {
|
||||||
// The privacy cost is the point of the setting, so it is stated
|
// The privacy cost is the point of the setting, so it is stated
|
||||||
// where the switch is, not buried elsewhere.
|
// where the switch is, not buried elsewhere.
|
||||||
@@ -192,14 +185,16 @@ private struct CollectOffersSection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct GrantLifetimeSection: View {
|
private struct GrantLifetimeSection: View {
|
||||||
let selection: GrantLifetimeOption
|
@ObservedObject var model: ContactsModel
|
||||||
let onSelect: (GrantLifetimeOption) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker(
|
Picker(
|
||||||
String(localized: L10n.Contacts.grantLifetimeTitle),
|
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
|
ForEach(GrantLifetimeOption.allCases) { option in
|
||||||
Text(Self.label(option)).tag(option)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
|||||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||||
struct SendScreen: View {
|
struct SendScreen: View {
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
/// Transfer pending an inline (list-level) delete confirmation.
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
@@ -60,7 +61,7 @@ struct SendScreen: View {
|
|||||||
onDismissed: model.shareSheetDidDismiss
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let shareTarget {
|
if let shareTarget {
|
||||||
TransferSharePanel(model: model, transfer: shareTarget)
|
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ struct SendScreen: View {
|
|||||||
/// alert attached here so they present from the detail's own context (presenting
|
/// alert attached here so they present from the detail's own context (presenting
|
||||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||||
private func detailView(for transfer: Transfer) -> some View {
|
private func detailView(for transfer: Transfer) -> some View {
|
||||||
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
|
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
@@ -101,7 +102,7 @@ struct SendScreen: View {
|
|||||||
onDismissed: model.shareSheetDidDismiss
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let panel = model.state.detailPanel {
|
if let panel = model.state.detailPanel {
|
||||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CoreImage.CIFilterBuiltins
|
|||||||
|
|
||||||
struct TransferDetailsView: View {
|
struct TransferDetailsView: View {
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
let events: [CoreEventModel]
|
let events: [CoreEventModel]
|
||||||
@State private var showStopConfirmation = false
|
@State private var showStopConfirmation = false
|
||||||
@@ -129,6 +130,7 @@ private struct DetailDestination: View {
|
|||||||
|
|
||||||
struct DetailPanelContent: View {
|
struct DetailPanelContent: View {
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
let panel: TransferDetailPanel
|
let panel: TransferDetailPanel
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ struct DetailPanelContent: View {
|
|||||||
onAccept: model.acceptReceiver
|
onAccept: model.acceptReceiver
|
||||||
)
|
)
|
||||||
case .share:
|
case .share:
|
||||||
TransferSharePanel(model: model, transfer: transfer)
|
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,6 +298,7 @@ private struct ReceiverRow: View {
|
|||||||
struct TransferSharePanel: View {
|
struct TransferSharePanel: View {
|
||||||
@Environment(\.vniColors) private var colors
|
@Environment(\.vniColors) private var colors
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -309,7 +312,7 @@ struct TransferSharePanel: View {
|
|||||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
|
||||||
case .preparing:
|
case .preparing:
|
||||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
|
|||||||
@@ -20,14 +20,25 @@ protocol TransferShareActions: AnyObject {
|
|||||||
struct ShareActionsView: View {
|
struct ShareActionsView: View {
|
||||||
@Environment(\.vniColors) private var colors
|
@Environment(\.vniColors) private var colors
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
let ticket: String
|
let ticket: String
|
||||||
|
|
||||||
@State private var actions: TransferShareActions = makePlatformShareActions()
|
@State private var actions: TransferShareActions = makePlatformShareActions()
|
||||||
@State private var writingNfc = false
|
@State private var writingNfc = false
|
||||||
|
@State private var choosingDevice = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
// Sending straight to a remembered device is another way to deliver
|
||||||
|
// this same invitation, so it belongs with the other delivery
|
||||||
|
// methods rather than in a separate flow.
|
||||||
|
if contacts.state.contacts.contains(where: \.canSend) {
|
||||||
|
SecondaryButton(
|
||||||
|
title: String(localized: L10n.Contacts.sendToDevice),
|
||||||
|
action: { choosingDevice = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
if actions.nfcAvailability != .hidden {
|
if actions.nfcAvailability != .hidden {
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||||
@@ -57,5 +68,8 @@ struct ShareActionsView: View {
|
|||||||
}, enabled: actions.canUseNativeShare)
|
}, enabled: actions.canUseNativeShare)
|
||||||
}
|
}
|
||||||
.onDisappear { actions.cancelNfcWrite() }
|
.onDisappear { actions.cancelNfcWrite() }
|
||||||
|
.sheet(isPresented: $choosingDevice) {
|
||||||
|
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ struct SettingsScreen: View {
|
|||||||
}
|
}
|
||||||
NavigationLink(value: SettingsSection.contacts) {
|
NavigationLink(value: SettingsSection.contacts) {
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon: .laptopcomputerAndIphone,
|
icon: .macbookAndIphone,
|
||||||
title: String(localized: L10n.Contacts.title),
|
title: String(localized: L10n.Contacts.title),
|
||||||
value: contacts.state.contacts.isEmpty
|
value: contacts.state.contacts.isEmpty
|
||||||
? nil
|
? nil
|
||||||
|
|||||||
@@ -5222,6 +5222,68 @@
|
|||||||
"nl": "Annuleer de overdracht om die in te trekken.",
|
"nl": "Annuleer de overdracht om die in te trekken.",
|
||||||
"ru": "Отмените передачу, чтобы отозвать её."
|
"ru": "Отмените передачу, чтобы отозвать её."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"contacts_send_to_device": {
|
||||||
|
"context": "Transfer share panel: action that sends this transfer straight to a remembered device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Send to a device",
|
||||||
|
"fr": "Envoyer à un appareil",
|
||||||
|
"es": "Enviar a un dispositivo",
|
||||||
|
"it": "Invia a un dispositivo",
|
||||||
|
"de": "An ein Gerät senden",
|
||||||
|
"pt": "Enviar para um dispositivo",
|
||||||
|
"pl": "Wyślij do urządzenia",
|
||||||
|
"nl": "Naar een apparaat sturen",
|
||||||
|
"ru": "Отправить на устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_pick_device_title": {
|
||||||
|
"context": "Device picker sheet: title when choosing which remembered device to send a transfer to.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Choose a device",
|
||||||
|
"fr": "Choisir un appareil",
|
||||||
|
"es": "Elegir un dispositivo",
|
||||||
|
"it": "Scegli un dispositivo",
|
||||||
|
"de": "Gerät auswählen",
|
||||||
|
"pt": "Escolher um dispositivo",
|
||||||
|
"pl": "Wybierz urządzenie",
|
||||||
|
"nl": "Kies een apparaat",
|
||||||
|
"ru": "Выберите устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_pick_device_empty": {
|
||||||
|
"context": "Device picker sheet: shown when no remembered device can currently be sent to.",
|
||||||
|
"translations": {
|
||||||
|
"en": "No device can be reached right now. Remembered devices appear here after a transfer.",
|
||||||
|
"fr": "Aucun appareil n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.",
|
||||||
|
"es": "Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.",
|
||||||
|
"it": "Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.",
|
||||||
|
"de": "Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.",
|
||||||
|
"pt": "Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.",
|
||||||
|
"pl": "Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.",
|
||||||
|
"nl": "Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.",
|
||||||
|
"ru": "Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_sent_to_device": {
|
||||||
|
"context": "Confirmation after a transfer was accepted by the device it was sent to. {device} = device name.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "device",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{device} accepted the transfer",
|
||||||
|
"fr": "{device} a accepté le transfert",
|
||||||
|
"es": "{device} aceptó la transferencia",
|
||||||
|
"it": "{device} ha accettato il trasferimento",
|
||||||
|
"de": "{device} hat die Übertragung angenommen",
|
||||||
|
"pt": "{device} aceitou a transferência",
|
||||||
|
"pl": "{device} zaakceptowało przesyłkę",
|
||||||
|
"nl": "{device} heeft de overdracht geaccepteerd",
|
||||||
|
"ru": "{device} принял передачу"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.</string>
|
<string name="contacts_offer_held">Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.</string>
|
||||||
<string name="contacts_waiting_title">Wartet auf Zustellung</string>
|
<string name="contacts_waiting_title">Wartet auf Zustellung</string>
|
||||||
<string name="contacts_waiting_hint">Brechen Sie die Übertragung ab, um sie zurückzuziehen.</string>
|
<string name="contacts_waiting_hint">Brechen Sie die Übertragung ab, um sie zurückzuziehen.</string>
|
||||||
|
<string name="contacts_send_to_device">An ein Gerät senden</string>
|
||||||
|
<string name="contacts_pick_device_title">Gerät auswählen</string>
|
||||||
|
<string name="contacts_pick_device_empty">Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s hat die Übertragung angenommen</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d Datei</item>
|
<item quantity="one">%1$d Datei</item>
|
||||||
<item quantity="other">%1$d Dateien</item>
|
<item quantity="other">%1$d Dateien</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.</string>
|
<string name="contacts_offer_held">Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">Pendientes de entrega</string>
|
<string name="contacts_waiting_title">Pendientes de entrega</string>
|
||||||
<string name="contacts_waiting_hint">Cancela la transferencia para retirarla.</string>
|
<string name="contacts_waiting_hint">Cancela la transferencia para retirarla.</string>
|
||||||
|
<string name="contacts_send_to_device">Enviar a un dispositivo</string>
|
||||||
|
<string name="contacts_pick_device_title">Elegir un dispositivo</string>
|
||||||
|
<string name="contacts_pick_device_empty">Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s aceptó la transferencia</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d archivo</item>
|
<item quantity="one">%1$d archivo</item>
|
||||||
<item quantity="other">%1$d archivos</item>
|
<item quantity="other">%1$d archivos</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.</string>
|
<string name="contacts_offer_held">Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">En attente de remise</string>
|
<string name="contacts_waiting_title">En attente de remise</string>
|
||||||
<string name="contacts_waiting_hint">Annulez le transfert pour le retirer.</string>
|
<string name="contacts_waiting_hint">Annulez le transfert pour le retirer.</string>
|
||||||
|
<string name="contacts_send_to_device">Envoyer à un appareil</string>
|
||||||
|
<string name="contacts_pick_device_title">Choisir un appareil</string>
|
||||||
|
<string name="contacts_pick_device_empty">Aucun appareil n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s a accepté le transfert</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d fichier</item>
|
<item quantity="one">%1$d fichier</item>
|
||||||
<item quantity="other">%1$d fichiers</item>
|
<item quantity="other">%1$d fichiers</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.</string>
|
<string name="contacts_offer_held">Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">In attesa di consegna</string>
|
<string name="contacts_waiting_title">In attesa di consegna</string>
|
||||||
<string name="contacts_waiting_hint">Annulla il trasferimento per ritirarlo.</string>
|
<string name="contacts_waiting_hint">Annulla il trasferimento per ritirarlo.</string>
|
||||||
|
<string name="contacts_send_to_device">Invia a un dispositivo</string>
|
||||||
|
<string name="contacts_pick_device_title">Scegli un dispositivo</string>
|
||||||
|
<string name="contacts_pick_device_empty">Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s ha accettato il trasferimento</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d file</item>
|
<item quantity="one">%1$d file</item>
|
||||||
<item quantity="other">%1$d file</item>
|
<item quantity="other">%1$d file</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.</string>
|
<string name="contacts_offer_held">Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.</string>
|
||||||
<string name="contacts_waiting_title">Wacht op bezorging</string>
|
<string name="contacts_waiting_title">Wacht op bezorging</string>
|
||||||
<string name="contacts_waiting_hint">Annuleer de overdracht om die in te trekken.</string>
|
<string name="contacts_waiting_hint">Annuleer de overdracht om die in te trekken.</string>
|
||||||
|
<string name="contacts_send_to_device">Naar een apparaat sturen</string>
|
||||||
|
<string name="contacts_pick_device_title">Kies een apparaat</string>
|
||||||
|
<string name="contacts_pick_device_empty">Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s heeft de overdracht geaccepteerd</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d bestand</item>
|
<item quantity="one">%1$d bestand</item>
|
||||||
<item quantity="other">%1$d bestanden</item>
|
<item quantity="other">%1$d bestanden</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.</string>
|
<string name="contacts_offer_held">To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">Oczekuje na dostarczenie</string>
|
<string name="contacts_waiting_title">Oczekuje na dostarczenie</string>
|
||||||
<string name="contacts_waiting_hint">Anuluj przesyłkę, aby ją wycofać.</string>
|
<string name="contacts_waiting_hint">Anuluj przesyłkę, aby ją wycofać.</string>
|
||||||
|
<string name="contacts_send_to_device">Wyślij do urządzenia</string>
|
||||||
|
<string name="contacts_pick_device_title">Wybierz urządzenie</string>
|
||||||
|
<string name="contacts_pick_device_empty">Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s zaakceptowało przesyłkę</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d plik</item>
|
<item quantity="one">%1$d plik</item>
|
||||||
<item quantity="few">%1$d pliki</item>
|
<item quantity="few">%1$d pliki</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.</string>
|
<string name="contacts_offer_held">Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">A aguardar entrega</string>
|
<string name="contacts_waiting_title">A aguardar entrega</string>
|
||||||
<string name="contacts_waiting_hint">Cancele a transferência para a retirar.</string>
|
<string name="contacts_waiting_hint">Cancele a transferência para a retirar.</string>
|
||||||
|
<string name="contacts_send_to_device">Enviar para um dispositivo</string>
|
||||||
|
<string name="contacts_pick_device_title">Escolher um dispositivo</string>
|
||||||
|
<string name="contacts_pick_device_empty">Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s aceitou a transferência</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d ficheiro</item>
|
<item quantity="one">%1$d ficheiro</item>
|
||||||
<item quantity="other">%1$d ficheiros</item>
|
<item quantity="other">%1$d ficheiros</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop.</string>
|
<string name="contacts_offer_held">Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">Ожидает доставки</string>
|
<string name="contacts_waiting_title">Ожидает доставки</string>
|
||||||
<string name="contacts_waiting_hint">Отмените передачу, чтобы отозвать её.</string>
|
<string name="contacts_waiting_hint">Отмените передачу, чтобы отозвать её.</string>
|
||||||
|
<string name="contacts_send_to_device">Отправить на устройство</string>
|
||||||
|
<string name="contacts_pick_device_title">Выберите устройство</string>
|
||||||
|
<string name="contacts_pick_device_empty">Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s принял передачу</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="one">%1$d файл</item>
|
<item quantity="one">%1$d файл</item>
|
||||||
<item quantity="few">%1$d файла</item>
|
<item quantity="few">%1$d файла</item>
|
||||||
|
|||||||
@@ -344,6 +344,10 @@
|
|||||||
<string name="contacts_offer_held">That device is not open. The transfer will be delivered the next time it opens VniDrop.</string>
|
<string name="contacts_offer_held">That device is not open. The transfer will be delivered the next time it opens VniDrop.</string>
|
||||||
<string name="contacts_waiting_title">Waiting to be delivered</string>
|
<string name="contacts_waiting_title">Waiting to be delivered</string>
|
||||||
<string name="contacts_waiting_hint">Cancel the transfer to withdraw it.</string>
|
<string name="contacts_waiting_hint">Cancel the transfer to withdraw it.</string>
|
||||||
|
<string name="contacts_send_to_device">Send to a device</string>
|
||||||
|
<string name="contacts_pick_device_title">Choose a device</string>
|
||||||
|
<string name="contacts_pick_device_empty">No device can be reached right now. Remembered devices appear here after a transfer.</string>
|
||||||
|
<string name="contacts_sent_to_device">%1$s accepted the transfer</string>
|
||||||
<plurals name="transfer_file_count">
|
<plurals name="transfer_file_count">
|
||||||
<item quantity="other">%1$d files</item>
|
<item quantity="other">%1$d files</item>
|
||||||
<item quantity="one">%1$d file</item>
|
<item quantity="one">%1$d file</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user