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>
219 lines
8.3 KiB
Swift
219 lines
8.3 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
struct RoomView: View {
|
|
@Bindable var store: TetherStore
|
|
@State private var draft = ""
|
|
@State private var showImporter = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
connectionBar
|
|
Divider()
|
|
if store.connected && !store.present.isEmpty {
|
|
presenceRow
|
|
Divider()
|
|
}
|
|
if store.connected && !store.nearby.isEmpty {
|
|
nearbyStrip
|
|
Divider()
|
|
}
|
|
if !store.transfers.isEmpty {
|
|
transfersRow
|
|
Divider()
|
|
}
|
|
feedList
|
|
Divider()
|
|
if !store.receipts.isEmpty {
|
|
seenByBar
|
|
Divider()
|
|
}
|
|
composer
|
|
}
|
|
.navigationTitle("tether")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task { store.connect() } // auto-connect on launch (defaults are production-ready)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Circle()
|
|
.fill(store.connected ? .green : .secondary)
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var connectionBar: some View {
|
|
VStack(spacing: 8) {
|
|
TextField("https://tether.pecord.io", text: $store.serverURL)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.keyboardType(.URL)
|
|
HStack {
|
|
TextField("room id (8 hex)", text: $store.room)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
Button(store.connected ? "Disconnect" : "Connect") {
|
|
store.connected ? store.disconnect() : store.connect()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
if let err = store.lastError {
|
|
Text(err).font(.caption).foregroundStyle(.red)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.textFieldStyle(.roundedBorder)
|
|
.padding()
|
|
}
|
|
|
|
// MARK: - Presence ("who's here")
|
|
|
|
private var presenceRow: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
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)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
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)
|
|
ProgressView(value: frac).frame(maxWidth: 120)
|
|
Text(t.done ? "done" : "\(Int(frac * 100))%")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 5)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private func sourceIcon(_ source: String) -> String {
|
|
if source.contains("iphone") || source.contains("ios") { return "iphone" }
|
|
if source.contains("ipad") { return "ipad" }
|
|
if source.contains("mac") { return "laptopcomputer" }
|
|
if source.contains("windows") { return "pc" }
|
|
return "desktopcomputer"
|
|
}
|
|
|
|
private var feedList: some View {
|
|
List(store.feed) { msg in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if let image = msg.image {
|
|
Image(uiImage: image)
|
|
.resizable().scaledToFit()
|
|
.frame(maxHeight: 220)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
} else if msg.isFile {
|
|
Label(msg.text, systemImage: "doc.fill").lineLimit(1)
|
|
} else {
|
|
Text(msg.text).lineLimit(4)
|
|
}
|
|
HStack {
|
|
Text(msg.isFile ? "file" : msg.source).font(.caption2)
|
|
Spacer()
|
|
Text(msg.date, style: .time).font(.caption2)
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { if !msg.isFile { store.copyToPasteboard(msg) } }
|
|
.swipeActions {
|
|
if !msg.isFile {
|
|
Button("Copy") { store.copyToPasteboard(msg) }.tint(.blue)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.overlay {
|
|
if store.feed.isEmpty {
|
|
ContentUnavailableView("No messages yet",
|
|
systemImage: "doc.on.clipboard",
|
|
description: Text("Connect, then send from any device in the room."))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var composer: some View {
|
|
HStack {
|
|
Button { showImporter = true } label: {
|
|
Image(systemName: "paperclip").font(.title2)
|
|
}
|
|
.disabled(!store.connected)
|
|
.fileImporter(isPresented: $showImporter, allowedContentTypes: [.item]) { result in
|
|
if case .success(let url) = result { store.sendFile(at: url) }
|
|
}
|
|
TextField("Type or paste…", text: $draft, axis: .vertical)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1...4)
|
|
Button {
|
|
if draft.isEmpty { store.sendClipboard() }
|
|
else { store.send(draft); draft = "" }
|
|
} label: {
|
|
Image(systemName: draft.isEmpty ? "doc.on.clipboard" : "paperplane.fill")
|
|
.font(.title2)
|
|
}
|
|
.disabled(!store.connected)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|