The SwiftUI app bundles the Go swarm engine through gomobile, so an iPhone is a real peer using the existing PAKE handshake, chunk verification, and relay fallback. XcodeGen builds the project and devicectl installs it on the paired phone.
277 lines
9.1 KiB
Swift
277 lines
9.1 KiB
Swift
import SwiftUI
|
|
|
|
struct RoomView: View {
|
|
@EnvironmentObject private var model: AppModel
|
|
|
|
var body: some View {
|
|
if let status = model.status {
|
|
VStack(spacing: 16) {
|
|
overviewCard(status)
|
|
codeCard(status)
|
|
filesCard(status)
|
|
actionButton(status)
|
|
}
|
|
.animation(.easeInOut(duration: 0.22), value: status.files)
|
|
.animation(.easeInOut(duration: 0.22), value: status.running)
|
|
}
|
|
}
|
|
|
|
// MARK: - Overview
|
|
|
|
private func overviewCard(_ status: RoomStatus) -> some View {
|
|
GlassCard {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: model.mode == .send
|
|
? [Theme.mint.opacity(0.2), Theme.mint.opacity(0.06)]
|
|
: [Theme.blue.opacity(0.2), Theme.blue.opacity(0.06)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 46, height: 46)
|
|
Image(systemName: model.mode == .send ? "arrow.up" : "arrow.down")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundStyle(model.mode == .send ? Theme.mint : Theme.blue)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(roomTitle(status))
|
|
.font(.system(.headline, design: .rounded).weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
Text(roomSubtitle(status))
|
|
.font(.footnote)
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
StatusPill(running: status.running, peers: status.peers)
|
|
}
|
|
|
|
if status.running {
|
|
HStack(spacing: 9) {
|
|
if model.isWaitingForPeer {
|
|
PulsingDot()
|
|
Text("Waiting for the other device to join")
|
|
} else {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Theme.mint)
|
|
Text("Connected · \(status.peers) peer\(status.peers == 1 ? "" : "s")")
|
|
}
|
|
}
|
|
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(.top, 14)
|
|
} else if let error = status.error, !error.isEmpty {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(Theme.danger)
|
|
Text(error)
|
|
.font(.footnote)
|
|
.foregroundStyle(Theme.danger.opacity(0.95))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.top, 13)
|
|
} else if model.mode == .receive && status.allComplete {
|
|
Label(
|
|
"All files received",
|
|
systemImage: "checkmark.seal.fill"
|
|
)
|
|
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
|
.foregroundStyle(Theme.mint)
|
|
.padding(.top, 13)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func roomTitle(_ status: RoomStatus) -> String {
|
|
if !status.running {
|
|
if let error = status.error, !error.isEmpty { return "Room stopped" }
|
|
if model.mode == .receive && status.allComplete { return "Room complete" }
|
|
return "Room stopped"
|
|
}
|
|
return model.mode == .send ? "Sharing files" : "Receiving files"
|
|
}
|
|
|
|
private func roomSubtitle(_ status: RoomStatus) -> String {
|
|
if !status.running {
|
|
if let error = status.error, !error.isEmpty {
|
|
return "The connection to the relay was lost."
|
|
}
|
|
if model.mode == .receive && status.allComplete {
|
|
return "The files are safe on your devices."
|
|
}
|
|
return "The room is no longer connected."
|
|
}
|
|
if model.mode == .send {
|
|
return "Keep this screen open while the other device downloads."
|
|
}
|
|
return "Files will appear here as they arrive."
|
|
}
|
|
|
|
// MARK: - Code
|
|
|
|
private func codeCard(_ status: RoomStatus) -> some View {
|
|
GlassCard {
|
|
FieldLabel(title: "Room code", hint: "The other device needs this exact code.")
|
|
|
|
HStack(spacing: 8) {
|
|
Text(status.code)
|
|
.font(.system(size: 21, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(.white)
|
|
.minimumScaleFactor(0.55)
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 13)
|
|
.padding(.vertical, 12)
|
|
.background(Color.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
|
.strokeBorder(Theme.stroke, lineWidth: 1)
|
|
}
|
|
.onTapGesture { model.copyCode() }
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityHint("Copies the room code")
|
|
|
|
Button {
|
|
model.copyCode()
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Theme.mint)
|
|
.frame(width: 42, height: 42)
|
|
.background(Theme.mint.opacity(0.1), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Copy room code")
|
|
|
|
ShareLink(item: status.code) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Theme.blue)
|
|
.frame(width: 42, height: 42)
|
|
.background(Theme.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
|
}
|
|
.accessibilityLabel("Share room code")
|
|
}
|
|
.padding(.top, 12)
|
|
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "network")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
Text(status.relay.isEmpty ? "public relay" : status.relay)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
.font(.system(size: 10.5, design: .monospaced))
|
|
.foregroundStyle(Theme.tertiaryText)
|
|
.padding(.top, 10)
|
|
}
|
|
}
|
|
|
|
// MARK: - Files
|
|
|
|
private func filesCard(_ status: RoomStatus) -> some View {
|
|
GlassCard {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
Text("Files")
|
|
.font(.system(.headline, design: .rounded).weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
Spacer()
|
|
if !status.files.isEmpty {
|
|
Text("\(status.files.count)")
|
|
.font(.system(size: 11, weight: .bold, design: .rounded))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.background(Color.white.opacity(0.06), in: Capsule())
|
|
}
|
|
}
|
|
|
|
if status.files.isEmpty {
|
|
VStack(spacing: 12) {
|
|
if status.running {
|
|
PulsingDot()
|
|
Text("Waiting for files…")
|
|
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
} else {
|
|
Image(systemName: "tray")
|
|
.font(.system(size: 22, weight: .medium))
|
|
.foregroundStyle(Theme.tertiaryText)
|
|
Text("No files in this room")
|
|
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 26)
|
|
} else {
|
|
VStack(spacing: 8) {
|
|
ForEach(sortedFiles(status.files)) { file in
|
|
FileRow(
|
|
file: file,
|
|
shareURL: model.mode == .receive ? model.receivedURL(for: file) : nil
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, 13)
|
|
|
|
if model.mode == .receive && status.allComplete {
|
|
Text("Tap the share button to save a file to Photos, Files, or another app.")
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.tertiaryText)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.top, 3)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sortedFiles(_ files: [FileProgress]) -> [FileProgress] {
|
|
files.sorted {
|
|
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func actionButton(_ status: RoomStatus) -> some View {
|
|
Group {
|
|
if status.running {
|
|
Button {
|
|
model.stopRoom()
|
|
} label: {
|
|
Label("Stop room", systemImage: "stop.fill")
|
|
.font(.system(.headline, design: .rounded).weight(.semibold))
|
|
.foregroundStyle(Theme.danger)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
Theme.danger.opacity(0.09),
|
|
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.strokeBorder(Theme.danger.opacity(0.18), lineWidth: 1)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else {
|
|
Button {
|
|
model.reset()
|
|
} label: {
|
|
Label("Start another room", systemImage: "arrow.counterclockwise")
|
|
}
|
|
.buttonStyle(PrimaryButtonStyle(tint: Theme.mint))
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
}
|