9291568ce0
ci / web (push) Failing after 35s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / docs-site (push) Failing after 40s
docker / deploy-docs (push) Successful in 16s
apple / swift (push) Successful in 1m20s
Split the monolithic ContentView into focused view files — a pure structural refactor with no behavior change (verified: builds macOS/iOS/tvOS, the test suite is green, and a fidelity review against the original found no discrepancies): - ContentView (272): the coordinator — owns the session model / host store / discovery, switches home<->session, holds the connect logic (it reads @AppStorage) + the dev hooks, and the stream builder (whose stable identity across awaiting-trust->streaming must NOT move — it stays here). - HomeView (251): the hosts grid + navigation + toolbar + sheets + "On this network" discovery section + empty state. - HostCards (158): HostCardView + DiscoveredCardView, sharing a CardMetrics struct (dedupes the platform-tuned sizing the two cards had copy-pasted). - TrustCardView (80): the TOFU prompt + fingerprint formatting. - StreamHUDView (67): the streaming overlay HUD. State flows idiomatically: @StateObject (ContentView) -> @ObservedObject in subviews, @State -> @Binding; the connect logic is passed as closures. Sheet placement is preserved — the pairing/speed-test sheets stay on the outer body so they survive the trust->home transition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.9 KiB
Swift
159 lines
5.9 KiB
Swift
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
|
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
|
private struct CardMetrics {
|
|
let iconSize: CGFloat
|
|
let iconBox: CGFloat
|
|
let cardPadding: CGFloat
|
|
let nameFont: Font
|
|
|
|
static var current: CardMetrics {
|
|
#if os(iOS)
|
|
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
|
#else
|
|
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
|
struct HostCardView: View {
|
|
let host: StoredHost
|
|
let isConnecting: Bool
|
|
let isMostRecent: Bool
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
let onPair: () -> Void
|
|
let onSpeedTest: () -> Void
|
|
let onForget: () -> Void
|
|
let onRemove: () -> Void
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
VStack(spacing: 10) {
|
|
ZStack {
|
|
Image(systemName: "play.display")
|
|
.font(.system(size: m.iconSize, weight: .light))
|
|
.foregroundStyle(.tint)
|
|
.opacity(isConnecting ? 0.3 : 1)
|
|
if isConnecting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.frame(height: m.iconBox)
|
|
VStack(spacing: 2) {
|
|
Text(host.displayName)
|
|
.font(m.nameFont)
|
|
.lineLimit(1)
|
|
HStack(spacing: 4) {
|
|
if host.pinnedSHA256 != nil {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("\(host.address):\(String(host.port))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
if let last = host.lastConnected {
|
|
Text("Connected \(last, format: .relative(presentation: .named))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, m.cardPadding)
|
|
.padding(.horizontal, 12)
|
|
#if !os(tvOS)
|
|
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
|
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay {
|
|
if isMostRecent {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
.contextMenu {
|
|
Button("Pair with PIN…", action: onPair)
|
|
Button("Test Network Speed…", action: onSpeedTest)
|
|
if host.pinnedSHA256 != nil {
|
|
Button("Forget Identity", action: onForget)
|
|
}
|
|
Button("Remove", role: .destructive, action: onRemove)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
|
/// tapping saves it and connects (or pairs, if the host requires it).
|
|
struct DiscoveredCardView: View {
|
|
let discovered: DiscoveredHost
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "play.display")
|
|
.font(.system(size: m.iconSize, weight: .light))
|
|
.foregroundStyle(.tint)
|
|
.frame(height: m.iconBox)
|
|
VStack(spacing: 2) {
|
|
Text(discovered.name)
|
|
.font(m.nameFont)
|
|
.lineLimit(1)
|
|
HStack(spacing: 4) {
|
|
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
Text("\(discovered.host):\(String(discovered.port))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, m.cardPadding)
|
|
.padding(.horizontal, 12)
|
|
#if !os(tvOS)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(
|
|
Color.secondary.opacity(0.25),
|
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
}
|
|
}
|