mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(apple): native SwiftUI app for iOS and macOS
Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
This commit is contained in:
146
apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift
Normal file
146
apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
||||
@MainActor
|
||||
protocol ReceiveInvitationActions: AnyObject {
|
||||
var fileAvailability: ReceiveMethodAvailability { get }
|
||||
var qrAvailability: ReceiveMethodAvailability { get }
|
||||
var nfcAvailability: ReceiveMethodAvailability { get }
|
||||
|
||||
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void)
|
||||
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void)
|
||||
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void)
|
||||
func cancel()
|
||||
}
|
||||
|
||||
/// Method chooser panel, ported from `ReceiveMethodPanel` in `ReceiveScreen.kt`.
|
||||
struct ReceiveMethodPanel: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: ReceiveModel
|
||||
@State private var actions: ReceiveInvitationActions = makeReceiveInvitationActions()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
|
||||
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
|
||||
|
||||
MethodRow(
|
||||
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
|
||||
availability: actions.fileAvailability
|
||||
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
|
||||
|
||||
if actions.qrAvailability != .hidden {
|
||||
MethodRow(
|
||||
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
|
||||
availability: actions.qrAvailability
|
||||
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
|
||||
}
|
||||
if actions.nfcAvailability != .hidden {
|
||||
MethodRow(
|
||||
icon: "wave.3.right",
|
||||
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
|
||||
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
|
||||
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
|
||||
) {
|
||||
model.setWaitingForNfc(true)
|
||||
actions.readNfcInvitation { model.onInvitationResult(.nfc, $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MethodRow: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
let icon: String
|
||||
var titleOverride: String? = nil
|
||||
let titleKey: String
|
||||
let descKey: String
|
||||
let availability: ReceiveMethodAvailability
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let enabled = availability == .available
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 22))
|
||||
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
if let titleOverride {
|
||||
Text(titleOverride).font(VniType.bodyLarge)
|
||||
} else {
|
||||
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
|
||||
}
|
||||
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
Spacer()
|
||||
if availability == .unavailable {
|
||||
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Invitation review panel, ported from `InvitationReviewPanel` in `ReceiveScreen.kt`.
|
||||
struct InvitationReviewPanel: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
@ObservedObject var model: ReceiveModel
|
||||
|
||||
private var state: ReceiveState { model.state }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
|
||||
if state.isInspecting {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||
}
|
||||
if let inspection = state.inspection {
|
||||
let metadata = inspection.metadata
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
|
||||
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
Field(label: String(localized: "field_receiver_name"),
|
||||
value: Binding(get: { state.receiverName }, set: model.setReceiverName))
|
||||
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||
.font(VniType.bodySmall)
|
||||
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
|
||||
|
||||
if state.isReceiving {
|
||||
let progressId = state.activeReceiveTransferId
|
||||
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
|
||||
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
|
||||
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
title: String(localized: "button_receive"), action: model.receive,
|
||||
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
}
|
||||
if let error = state.lastReceiveError {
|
||||
Text(error.resolved()).font(VniType.bodySmall).foregroundStyle(colors.destructiveDefault)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
265
apple/VniDrop/Features/Receive/ReceiveModel.swift
Normal file
265
apple/VniDrop/Features/Receive/ReceiveModel.swift
Normal file
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Which acquisition method produced an invitation, ported from `ReceiveMethod`.
|
||||
enum ReceiveMethod {
|
||||
case invitationFile
|
||||
case qrCode
|
||||
case nfc
|
||||
}
|
||||
|
||||
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||
case transfer(transferId: UInt64)
|
||||
case all
|
||||
}
|
||||
|
||||
/// Receive feature state, ported from `feature/receive/ReceiveViewModel.kt`.
|
||||
struct ReceiveState: Equatable {
|
||||
var isAcquisitionOpen = false
|
||||
var ticket = ""
|
||||
var method: ReceiveMethod?
|
||||
var inspection: TicketInspectionModel?
|
||||
var receiverName = ""
|
||||
var receiveFolder: ReceiveFolder?
|
||||
var folderAccessStatus: FolderAccessStatus = .unavailable
|
||||
var isInspecting = false
|
||||
var isReceiving = false
|
||||
var activeReceiveTransferId: UInt64?
|
||||
var lastReceiveError: UiText?
|
||||
var isWaitingForNfc = false
|
||||
var historyDeleteTarget: ReceiveHistoryDeleteTarget?
|
||||
var isDeletingHistory = false
|
||||
|
||||
func canReceive(coreInitialized: Bool) -> Bool {
|
||||
coreInitialized && !ticket.isEmpty && inspection != nil
|
||||
&& folderAccessStatus == .writable && !isReceiving && !isInspecting
|
||||
}
|
||||
|
||||
static func == (lhs: ReceiveState, rhs: ReceiveState) -> Bool {
|
||||
lhs.isAcquisitionOpen == rhs.isAcquisitionOpen && lhs.ticket == rhs.ticket
|
||||
&& lhs.inspection == rhs.inspection && lhs.receiverName == rhs.receiverName
|
||||
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||
&& lhs.isInspecting == rhs.isInspecting && lhs.isReceiving == rhs.isReceiving
|
||||
&& lhs.activeReceiveTransferId == rhs.activeReceiveTransferId
|
||||
&& lhs.lastReceiveError == rhs.lastReceiveError && lhs.isWaitingForNfc == rhs.isWaitingForNfc
|
||||
&& lhs.historyDeleteTarget == rhs.historyDeleteTarget && lhs.isDeletingHistory == rhs.isDeletingHistory
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReceiveModel: ObservableObject {
|
||||
@Published private(set) var state = ReceiveState()
|
||||
@Published private(set) var coreState = CoreState()
|
||||
|
||||
private let repository: CoreRepository
|
||||
private let fileSystemService: FileSystemService
|
||||
private let messages: UiMessageController
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
repository: CoreRepository,
|
||||
fileSystemService: FileSystemService,
|
||||
preferences: AppPreferencesRepository,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.repository = repository
|
||||
self.fileSystemService = fileSystemService
|
||||
self.messages = messages
|
||||
|
||||
repository.$state.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
|
||||
|
||||
preferences.$preferences
|
||||
.sink { [weak self] prefs in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
|
||||
let status = await self.fileSystemService.validateReceiveFolder(folder)
|
||||
if self.state.receiverName.isEmpty { self.state.receiverName = prefs.username }
|
||||
self.state.receiveFolder = folder
|
||||
self.state.folderAccessStatus = status
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
if case .transfersChanged(let id) = signal {
|
||||
Task { _ = await self.repository.refresh() }
|
||||
if self.state.isReceiving && id != 0 {
|
||||
self.state.activeReceiveTransferId = id
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func openAcquisition() { state.isAcquisitionOpen = true }
|
||||
func dismissAcquisition() {
|
||||
if !state.isReceiving && !state.isInspecting { resetAcquisition() }
|
||||
}
|
||||
func setReceiverName(_ value: String) { state.receiverName = value }
|
||||
func setWaitingForNfc(_ waiting: Bool) { state.isWaitingForNfc = waiting }
|
||||
|
||||
func requestDeleteHistoryItem(_ transferId: UInt64) {
|
||||
let canDelete = coreState.transfers.contains {
|
||||
$0.transferId == transferId && $0.direction == .receive && $0.status.isTerminalReceiveHistory
|
||||
}
|
||||
if canDelete { state.historyDeleteTarget = .transfer(transferId: transferId) }
|
||||
}
|
||||
|
||||
func requestClearHistory() {
|
||||
if coreState.transfers.contains(where: { $0.direction == .receive && $0.status.isTerminalReceiveHistory }) {
|
||||
state.historyDeleteTarget = .all
|
||||
}
|
||||
}
|
||||
|
||||
func dismissHistoryDelete() {
|
||||
if !state.isDeletingHistory { state.historyDeleteTarget = nil }
|
||||
}
|
||||
|
||||
func confirmHistoryDelete() {
|
||||
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
|
||||
state.isDeletingHistory = true
|
||||
Task {
|
||||
let result: Result<Void, Error>
|
||||
switch target {
|
||||
case .transfer(let id):
|
||||
result = await repository.delete(transferId: id)
|
||||
case .all:
|
||||
result = (await repository.clearReceiveHistory()).map { _ in () }
|
||||
}
|
||||
switch result {
|
||||
case .success:
|
||||
state.historyDeleteTarget = nil
|
||||
state.isDeletingHistory = false
|
||||
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
|
||||
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isDeletingHistory = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onInvitationResult(_ method: ReceiveMethod, _ result: Result<String, Error>) {
|
||||
state.isWaitingForNfc = false
|
||||
switch result {
|
||||
case .success(let raw): inspectInvitation(method, raw)
|
||||
case .failure(let error): messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func receive() {
|
||||
let current = state
|
||||
guard let folder = current.receiveFolder else { return }
|
||||
if !current.canReceive(coreInitialized: coreState.isInitialized) { return }
|
||||
state.isReceiving = true
|
||||
state.lastReceiveError = nil
|
||||
state.activeReceiveTransferId = nil
|
||||
Task {
|
||||
let result: Result<Void, Error>
|
||||
if folder.kind == .iosSecurityScopedUrl {
|
||||
result = await repository.receiveIntoSecurityScopedDirectory(
|
||||
ticket: current.ticket, outputDirectoryUrl: folder.value, receiverName: current.receiverName
|
||||
)
|
||||
} else {
|
||||
result = await repository.receive(
|
||||
ticket: current.ticket, outputDir: folder.value, receiverName: current.receiverName
|
||||
)
|
||||
}
|
||||
switch result {
|
||||
case .success:
|
||||
resetAcquisition()
|
||||
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
|
||||
messages.tryShow(UiMessage(
|
||||
text: .resource("receive_completed"),
|
||||
tone: .success,
|
||||
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
||||
onAction: canReveal ? { [weak self] in self?.revealReceiveFolder(folder) } : nil
|
||||
))
|
||||
case .failure(let error):
|
||||
if error.isUserCancellation {
|
||||
state.isReceiving = false
|
||||
state.activeReceiveTransferId = nil
|
||||
state.lastReceiveError = nil
|
||||
return
|
||||
}
|
||||
let uiText = error.toUiText()
|
||||
state.isReceiving = false
|
||||
state.activeReceiveTransferId = nil
|
||||
state.lastReceiveError = uiText
|
||||
messages.tryShow(UiMessage(
|
||||
text: uiText,
|
||||
tone: .error,
|
||||
actionLabel: .resource("button_retry"),
|
||||
onAction: { [weak self] in self?.receive() }
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelActiveReceive() {
|
||||
let transferId = state.activeReceiveTransferId
|
||||
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
|
||||
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||
guard let transferId else { return }
|
||||
Task {
|
||||
let result = await repository.cancel(transferId: transferId)
|
||||
switch result {
|
||||
case .success:
|
||||
state.isReceiving = false
|
||||
state.activeReceiveTransferId = nil
|
||||
state.lastReceiveError = nil
|
||||
_ = await repository.refresh()
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealReceiveFolder(_ folder: ReceiveFolder) {
|
||||
Task {
|
||||
let result = await fileSystemService.revealReceiveFolder(folder)
|
||||
if case .failure = result {
|
||||
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
|
||||
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
|
||||
state.isAcquisitionOpen = true
|
||||
state.ticket = ticket
|
||||
state.method = method
|
||||
state.inspection = nil
|
||||
state.isInspecting = true
|
||||
Task {
|
||||
let result = await repository.inspectTicket(ticket)
|
||||
switch result {
|
||||
case .success(let inspection):
|
||||
state.inspection = inspection
|
||||
state.isInspecting = false
|
||||
case .failure(let error):
|
||||
state.ticket = ""
|
||||
state.method = nil
|
||||
state.inspection = nil
|
||||
state.isInspecting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAcquisition() {
|
||||
state.isAcquisitionOpen = false
|
||||
state.ticket = ""
|
||||
state.method = nil
|
||||
state.inspection = nil
|
||||
state.isInspecting = false
|
||||
state.isReceiving = false
|
||||
state.activeReceiveTransferId = nil
|
||||
state.lastReceiveError = nil
|
||||
state.isWaitingForNfc = false
|
||||
}
|
||||
}
|
||||
149
apple/VniDrop/Features/Receive/ReceiveScreen.swift
Normal file
149
apple/VniDrop/Features/Receive/ReceiveScreen.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
|
||||
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
|
||||
struct ReceiveScreen: View {
|
||||
@ObservedObject var model: ReceiveModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
private var transfers: [Transfer] {
|
||||
model.coreState.transfers.filter { $0.direction == .receive }
|
||||
}
|
||||
private var deletable: [Transfer] {
|
||||
transfers.filter { $0.status.isTerminalReceiveHistory }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if transfers.isEmpty {
|
||||
ScrollView { emptyState }
|
||||
} else {
|
||||
history
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(LocalizedStringKey("receive_title")))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: model.openAcquisition) {
|
||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||
}
|
||||
}
|
||||
if !deletable.isEmpty {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(role: .destructive, action: model.requestClearHistory) {
|
||||
Label(String(localized: "receive_clear_history"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { model.state.isAcquisitionOpen }, set: { _ in }),
|
||||
windowClass: windowClass,
|
||||
onDismiss: model.dismissAcquisition
|
||||
) {
|
||||
if model.state.ticket.isEmpty {
|
||||
ReceiveMethodPanel(model: model)
|
||||
} else {
|
||||
InvitationReviewPanel(model: model)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
|
||||
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
|
||||
) {
|
||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
|
||||
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
|
||||
role: .destructive, action: model.confirmHistoryDelete)
|
||||
} message: {
|
||||
historyDeleteMessage
|
||||
}
|
||||
}
|
||||
|
||||
private var history: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(transfers) { transfer in
|
||||
ReceiveTransferRow(
|
||||
transfer: transfer,
|
||||
progress: progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
|
||||
)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if transfer.status.isTerminalReceiveHistory {
|
||||
Button(role: .destructive) {
|
||||
model.requestDeleteHistoryItem(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(LocalizedStringKey("receive_history_title"))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("receive_new_subtitle"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
EmptyStateView(
|
||||
systemImage: "tray.and.arrow.down",
|
||||
title: String(localized: "receive_empty_title"),
|
||||
message: String(localized: "receive_empty_body")
|
||||
) {
|
||||
Button(action: model.openAcquisition) {
|
||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
private var clearAllPending: Bool { model.state.historyDeleteTarget == .all }
|
||||
|
||||
@ViewBuilder
|
||||
private var historyDeleteMessage: some View {
|
||||
if let target = model.state.historyDeleteTarget {
|
||||
if target == .all {
|
||||
Text(LocalizedStringKey("receive_clear_history_description"))
|
||||
} else {
|
||||
Text(String(format: String(localized: "receive_delete_history_description"),
|
||||
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func transferName(for target: ReceiveHistoryDeleteTarget) -> String? {
|
||||
if case .transfer(let id) = target {
|
||||
return transfers.first { $0.transferId == id }?.transferName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReceiveTransferRow: View {
|
||||
let transfer: Transfer
|
||||
let progress: TransferProgress?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "doc")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
|
||||
.font(.body).lineLimit(1)
|
||||
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if transfer.status == .receiving, let progress {
|
||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user