M4: NucleicRemote iPhone app — SwiftUI client over the shared protocol
A real iOS Xcode app (ios/NucleicRemote) linking the NucleicProtocol SwiftPM library as a local package. Builds for the iOS 27 simulator and launches to the pairing screen. - Transport: NWFrameChannel (NWConnection) + LANDiscovery (Bonjour _nucleic._tcp). - Engine: drives NucleicProtocol.SyncClient (Noise XXpsk0 pair / IK reconnect, hello/welcome, HostMsg→Event stream). - State: RemoteStore (ObservableObject) — the single on-device projection of host state; IdentityStore persists the device identity (Keychain) + pinned host. - UI (UX_IOS): attention-first SessionsView, SessionDetailView (transcript/diff + status-driven action area / composer), ApprovalCardView with Face ID gate on high-risk approvals + allow-always menu, PairingScannerView (AVFoundation QR), SettingsView, connection chip. Same status glyphs/semantics as the Mac. Add-iPhone QR display + server start live on the macOS side (follow-up); push / Live Activity are M5 (needs the relay). gitignore keeps this .xcodeproj despite the blanket *.xcodeproj rule. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, with a
|
||||
/// persistent connection chip. Tapping a row pushes detail.
|
||||
struct SessionsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||
let sorted = store.sessions.sorted {
|
||||
let a = StatusStyle.sortRank($0.status), b = StatusStyle.sortRank($1.status)
|
||||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
||||
}
|
||||
let needs = sorted.filter { $0.status == .awaitingApproval || $0.status == .awaitingInput }
|
||||
let running = sorted.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
||||
let done = sorted.filter { $0.status.isTerminal }
|
||||
return [
|
||||
("Needs you", needs), ("Running", running), ("Done", done),
|
||||
].filter { !$0.rows.isEmpty }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.sessions.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No sessions", systemImage: "square.stack.3d.up",
|
||||
description: Text(store.connectivity.isLive
|
||||
? "Start a session on your Mac to see it here."
|
||||
: "Waiting to connect to \(store.hostName)…"))
|
||||
} else {
|
||||
List {
|
||||
ForEach(grouped, id: \.title) { group in
|
||||
Section(group.title.uppercased()) {
|
||||
ForEach(group.rows, id: \.sessionID) { summary in
|
||||
NavigationLink {
|
||||
SessionDetailView(sessionID: summary.sessionID)
|
||||
} label: {
|
||||
SessionRow(summary: summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { store.refreshSessions() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
ConnectionChip().padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionRow: View {
|
||||
let summary: WireSessionSummary
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: StatusStyle.symbol(summary.status))
|
||||
.foregroundStyle(StatusStyle.color(summary.status))
|
||||
.font(.title3)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||||
Text("+\(diff.added)/−\(diff.removed)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user