Files
tether/ios/MacTetherApp/Sources/ContentView.swift
Patrick Ecord c9546ac222 core+apps: file transfer progress
Add a Transfer record {name, received, total, incoming, done} and an engine-side
transfers map updated by the RTC/LAN transports — fine-grained on receive (per
chunk in apply_file_frame), per-chunk on send. Engine.transfers() exposes
in-flight transfers (completed linger ~6s, then prune). Both apps poll it and
show a progress row with a bar + percent above the feed.

Verified with examples/progress_demo.rs: a 5MB file's transfer reports
received/total advancing to done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:57:43 -05:00

256 lines
9.2 KiB
Swift

import SwiftUI
struct ContentView: View {
@Bindable var store: MacTetherStore
@State private var draft = ""
var body: some View {
VStack(spacing: 0) {
toolbar
Divider()
if store.connected && !store.present.isEmpty {
presenceRow
Divider()
}
if store.connected && !store.nearby.isEmpty {
nearbyStrip
Divider()
}
if !store.transfers.isEmpty {
transfersRow
Divider()
}
feedArea
Divider()
if !store.receipts.isEmpty {
seenByBar
Divider()
}
composer
}
.frame(minWidth: 480, minHeight: 400)
.task { store.connectIfSaved() }
}
// MARK: - Delivery receipts
private var seenByBar: some View {
let names = Array(Set(store.receipts.map(\.name))).sorted()
return HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green).font(.caption)
Text("Seen by \(names.joined(separator: ", "))")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - File transfers (progress)
private var transfersRow: some View {
VStack(spacing: 4) {
ForEach(store.transfers, id: \.name) { t in
let frac = t.total > 0 ? Double(t.received) / Double(t.total) : 0
HStack(spacing: 8) {
Image(systemName: t.incoming ? "arrow.down.circle" : "arrow.up.circle")
.foregroundStyle(.secondary)
Text(t.name).font(.caption).lineLimit(1).frame(maxWidth: 140, alignment: .leading)
ProgressView(value: frac).frame(width: 120)
Text(t.done ? "done" : "\(Int(frac * 100))%")
.font(.caption2).foregroundStyle(.secondary)
Spacer()
}
}
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - Presence ("who's here")
private var presenceRow: some View {
HStack(spacing: 12) {
ForEach(store.present, id: \.id) { peer in
HStack(spacing: 5) {
Circle().fill(.green).frame(width: 7, height: 7)
Image(systemName: sourceIcon(peer.source)).font(.caption2)
Text(peer.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 14).padding(.vertical, 5)
}
// MARK: - Nearby devices (AirDrop-style picker)
private var nearbyStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
Label("Nearby", systemImage: "dot.radiowaves.left.and.right")
.font(.caption).foregroundStyle(.secondary)
.padding(.trailing, 4)
ForEach(store.nearby, id: \.id) { peer in
let here = peer.room == store.room
Button {
store.pair(with: peer)
} label: {
HStack(spacing: 6) {
Image(systemName: sourceIcon(peer.source))
VStack(alignment: .leading, spacing: 1) {
Text(peer.name).font(.caption).lineLimit(1)
Text(here ? "in your room" : "tap to join")
.font(.caption2)
.foregroundStyle(here ? .green : .secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color.primary.opacity(0.06), in: Capsule())
}
.buttonStyle(.plain)
.disabled(here)
.help(here ? "Already in this room" : "Join \(peer.name)'s room")
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
}
// MARK: - Toolbar
private var toolbar: some View {
HStack(spacing: 10) {
Circle()
.fill(store.connected ? Color.green : Color.secondary.opacity(0.5))
.frame(width: 10, height: 10)
.animation(.easeInOut, value: store.connected)
if store.connected {
Text("Room \(store.room)").font(.callout).foregroundStyle(.secondary)
} else {
TextField("Server URL", text: $store.serverURL)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 240)
}
Spacer()
if let err = store.lastError, store.hasAttemptedConnect {
Text(err).font(.caption).foregroundStyle(.red)
}
Button(store.connected ? "Disconnect" : "Connect") {
store.connected ? store.disconnect() : store.connect()
}
.buttonStyle(.borderedProminent)
.tint(store.connected ? .secondary : .accentColor)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// MARK: - Feed
private var feedArea: some View {
List(store.feed) { msg in
feedRow(msg)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
}
.listStyle(.plain)
.overlay {
if store.feed.isEmpty {
ContentUnavailableView(
"No messages yet",
systemImage: "link.circle",
description: Text(store.connected
? "Copy anything on a connected device — it'll appear here."
: "Connect to your tether server to start syncing.")
)
}
}
}
private func feedRow(_ item: FeedItem) -> some View {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
if let image = item.image {
Image(nsImage: image)
.resizable().scaledToFit()
.frame(maxWidth: 280, maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else if item.isFile {
Label(item.text, systemImage: "doc.fill")
.font(.body).lineLimit(1)
} else {
Text(item.text)
.font(.body).lineLimit(6).textSelection(.enabled)
}
HStack {
Label(item.isFile ? "file" : item.source,
systemImage: item.isFile ? "arrow.down.circle" : sourceIcon(item.source))
.font(.caption).foregroundStyle(.secondary)
Spacer()
Text(item.date, style: .time)
.font(.caption).foregroundStyle(.secondary)
}
}
Button {
if let url = item.fileURL {
NSWorkspace.shared.activateFileViewerSelecting([url]) // reveal in Finder
} else {
store.copyToPasteboard(item)
}
} label: {
Image(systemName: item.isFile ? "magnifyingglass" : "doc.on.doc")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(item.isFile ? "Reveal in Finder" : "Copy to clipboard")
}
.padding(10)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
}
private func sourceIcon(_ source: String) -> String {
let s = source
if s.contains("iphone") || s.contains("ios") { return "iphone" }
if s.contains("ipad") { return "ipad" }
if s.contains("macos") || s.contains("mac") { return "laptopcomputer" }
if s.contains("windows") { return "pc" }
return "desktopcomputer"
}
// MARK: - Composer
private var composer: some View {
HStack(spacing: 10) {
Button {
store.attachFile()
} label: {
Image(systemName: "paperclip")
}
.buttonStyle(.plain)
.disabled(!store.connected)
.help("Send a file or image")
TextField("Type a message…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
.onSubmit {
guard !draft.isEmpty else { return }
store.send(draft); draft = ""
}
Button {
store.send(draft); draft = ""
} label: {
Image(systemName: "paperplane.fill")
}
.keyboardShortcut(.return, modifiers: .command)
.buttonStyle(.borderedProminent)
.disabled(!store.connected || draft.isEmpty)
}
.padding(12)
}
}