aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS/TransfersView.swift
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-07 10:19:42 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-07 10:19:42 -0800
commit87f08cf60a5d7c1cf618463916cbac4dab88e0f8 (patch)
treec15a94443801beff5fcb8ac5d5dbc8276726ee39 /Hotline/macOS/TransfersView.swift
parentbcf36ef614aa46ae3d8e714add470f749fdf3714 (diff)
Massive refactor of transfer cients (new folder upload implementation), brand new async version of NetSocket, and a rewritten Hotline view model. New cross-server transfers UI.
Diffstat (limited to 'Hotline/macOS/TransfersView.swift')
-rw-r--r--Hotline/macOS/TransfersView.swift169
1 files changed, 169 insertions, 0 deletions
diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift
new file mode 100644
index 0000000..9932c0a
--- /dev/null
+++ b/Hotline/macOS/TransfersView.swift
@@ -0,0 +1,169 @@
+import SwiftUI
+
+struct TransfersView: View {
+ @Environment(\.appState) private var appState
+
+ var body: some View {
+ VStack(spacing: 0) {
+ if appState.transfers.isEmpty {
+ emptyState
+ } else {
+ transfersList
+ }
+ }
+ .frame(minWidth: 500, minHeight: 200)
+ .navigationTitle("Transfers")
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ appState.cancelAllTransfers()
+ } label: {
+ Label("Cancel All", systemImage: "xmark.circle")
+ }
+ .disabled(appState.transfers.isEmpty)
+ }
+ }
+ }
+
+ // MARK: - Empty State
+
+ private var emptyState: some View {
+ ContentUnavailableView {
+ Label("No Transfers", systemImage: "arrow.up.arrow.down")
+ } description: {
+ Text("Your Hotline file transfers will appear here")
+ }
+ }
+
+ // MARK: - Transfers List
+
+ private var transfersList: some View {
+ List {
+ ForEach(appState.transfers) { transfer in
+ TransferRow(transfer: transfer)
+ }
+ }
+ .listStyle(.inset(alternatesRowBackgrounds: true))
+ }
+}
+
+// MARK: - Transfer Row
+
+struct TransferRow: View {
+ @Bindable var transfer: TransferInfo
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // File name and server
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(transfer.title)
+ .font(.system(.body, design: .default, weight: .medium))
+
+ if let serverName = transfer.serverName {
+ Text(serverName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Spacer()
+
+ // Cancel button
+ Button {
+ AppState.shared.cancelTransfer(id: transfer.id)
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help("Cancel download")
+ }
+
+ // Progress bar and status
+ VStack(alignment: .leading, spacing: 4) {
+ if transfer.failed {
+ Label("Failed", systemImage: "exclamationmark.triangle.fill")
+ .font(.caption)
+ .foregroundStyle(.red)
+ } else if transfer.completed {
+ Label("Complete", systemImage: "checkmark.circle.fill")
+ .font(.caption)
+ .foregroundStyle(.green)
+ } else {
+ // Progress bar
+ ProgressView(value: transfer.progress, total: 1.0)
+ .progressViewStyle(.linear)
+
+ // Progress info
+ HStack(spacing: 8) {
+ // Progress percentage
+ Text("\(Int(transfer.progress * 100))%")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+
+ // File size
+ Text(formatSize(transfer.size))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ // Speed
+ if let speed = transfer.speed {
+ Text(formatSpeed(speed))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+
+ // Time remaining
+ if let timeRemaining = transfer.timeRemaining {
+ Text(formatTimeRemaining(timeRemaining))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ }
+ }
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ // MARK: - Formatting
+
+ private func formatSize(_ bytes: UInt) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.countStyle = .file
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ return formatter.string(fromByteCount: Int64(bytes))
+ }
+
+ private func formatSpeed(_ bytesPerSecond: Double) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.countStyle = .file
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s"
+ }
+
+ private func formatTimeRemaining(_ seconds: TimeInterval) -> String {
+ if seconds < 60 {
+ return "\(Int(seconds))s"
+ } else if seconds < 3600 {
+ let minutes = Int(seconds / 60)
+ let secs = Int(seconds.truncatingRemainder(dividingBy: 60))
+ return "\(minutes)m \(secs)s"
+ } else {
+ let hours = Int(seconds / 3600)
+ let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60)
+ return "\(hours)h \(minutes)m"
+ }
+ }
+}
+
+// MARK: - Preview
+
+#Preview {
+ TransfersView()
+ .environment(AppState.shared)
+}