Brings the phone's ambient surfaces (UX_IOS §5/§8) to maturity: - Notification pipeline (NotificationRouter): local notifications for approvals and needs-input transitions while backgrounded; low-risk approvals are actionable from the banner (Allow requires device auth, high-risk must open the app's Face ID gate); taps deep-link to the session; app-icon badge = NEEDS YOU count; resolutions withdraw the notification (first-responder-wins). The relay's content-free approval.pending tickle localizes via Localizable.strings. - Live Activity: new NucleicRemoteWidgets extension target (lock screen + Dynamic Island) rendering one aggregate Activity — N running / M waiting + the most urgent session — started/updated/ ended by LiveActivityManager as session state changes. - Out-of-band push path: nucleic-edge gains POST /v1/push/register (admin) so the host can upload tokens for LAN-only pairings; the host's new PushRelayClient (config-gated on NUCLEIC_RELAY_URL + NUCLEIC_RELAY_ADMIN_SECRET) mirrors Hello.pushToken to the relay and wakes non-connected phones when an approval arrives, throttled per device. Everything stays off until the relay is provisioned. Worker tests (23) and Swift suites pass apart from the pre-existing fixture gaps and nvrsion flake. Simulated APNs delivery is blocked in this environment (notification auth can't be granted headlessly). Co-Authored-By: Claude Fable 5 <[email protected]>
190 lines
9.0 KiB
Swift
190 lines
9.0 KiB
Swift
import SwiftUI
|
||
import NucleicProtocol
|
||
|
||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived
|
||
/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done".
|
||
struct SessionsView: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
@State private var showArchived = false
|
||
@State private var path = NavigationPath()
|
||
|
||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||
let pool = (showArchived ? store.sessions : store.liveSessions)
|
||
.sorted {
|
||
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
|
||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
||
}
|
||
let needs = pool.filter { $0.status.needsYou($0.disposition) }
|
||
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
||
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
|
||
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack(path: $path) {
|
||
Group {
|
||
if store.liveSessions.isEmpty && !showArchived {
|
||
ContentUnavailableView(
|
||
"No sessions", systemImage: "square.stack.3d.up",
|
||
description: Text(store.connectivity.isLive
|
||
? "Start a session from Home or the 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(value: summary.sessionID) {
|
||
SessionRow(summary: summary)
|
||
}
|
||
.listRowBackground(SessionRowWash(summary: summary))
|
||
.swipeActions(edge: .leading) {
|
||
Button {
|
||
store.setFavorite(summary.sessionID, !summary.favorite)
|
||
} label: {
|
||
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
|
||
}.tint(.yellow)
|
||
}
|
||
.swipeActions(edge: .trailing) {
|
||
Button(role: .destructive) {
|
||
store.deleteSession(summary.sessionID)
|
||
} label: { Label("Delete", systemImage: "trash") }
|
||
Button {
|
||
store.setArchived(summary.sessionID, !summary.archived)
|
||
} label: {
|
||
Label(summary.archived ? "Unarchive" : "Archive",
|
||
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
||
}.tint(.gray)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.refreshable { store.refreshSessions() }
|
||
}
|
||
}
|
||
.navigationTitle("Sessions")
|
||
.navigationDestination(for: SessionID.self) { id in
|
||
SessionDetailView(sessionID: id)
|
||
}
|
||
// Offline UI previews (with NUCLEIC_DEMO): jump straight into one session.
|
||
.onAppear {
|
||
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"],
|
||
!raw.isEmpty, path.isEmpty {
|
||
path.append(SessionID(rawValue: raw))
|
||
}
|
||
consumeRoute()
|
||
}
|
||
// A notification tap while this tab is already up.
|
||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
showArchived.toggle()
|
||
} label: {
|
||
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
|
||
}
|
||
}
|
||
}
|
||
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||
}
|
||
}
|
||
|
||
/// Push the session a notification tap asked for, then clear the request.
|
||
private func consumeRoute() {
|
||
guard let route = store.pendingRoute else { return }
|
||
store.pendingRoute = nil
|
||
path.append(route)
|
||
}
|
||
}
|
||
|
||
struct SessionRow: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
let summary: WireSessionSummary
|
||
/// Show the project name as subtext. Suppressed when the row already lives inside
|
||
/// its project's own section (e.g. `ProjectDetailView`), where the name is redundant.
|
||
var showProjectName = true
|
||
|
||
/// Whether Orchestra is latched on (the effort carries the sentinel).
|
||
private var orchestra: Bool {
|
||
guard let effort = summary.effort else { return false }
|
||
return effort.caseInsensitiveCompare(store.modelCatalog.orchestraSentinelOrFallback) == .orderedSame
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition))
|
||
.foregroundStyle(Palette.status(summary.status, disposition: summary.disposition))
|
||
.font(.title3)
|
||
.frame(width: 24)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 5) {
|
||
if summary.favorite {
|
||
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
|
||
}
|
||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||
// The same trailing markers the Mac sidebar row carries: auto-approval
|
||
// bolt, Orchestra note, autoship box.
|
||
if summary.auto {
|
||
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||
}
|
||
if orchestra {
|
||
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
|
||
}
|
||
if summary.autoShip {
|
||
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||
}
|
||
}
|
||
if showProjectName {
|
||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
Spacer()
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
if summary.pendingApprovalCount > 0 {
|
||
Text("\(summary.pendingApprovalCount)")
|
||
.font(.caption2.bold())
|
||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||
.background(Palette.attention, in: Capsule())
|
||
.foregroundStyle(.white)
|
||
}
|
||
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||
Text("+\(diff.added)/−\(diff.removed)")
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
}
|
||
|
||
/// The Mac sidebar's row washes, mirrored: a breathing amber fill on a chat that's blocked on
|
||
/// you, and a steady green fill on a chat that finished while you weren't looking. Applied as
|
||
/// a `listRowBackground` so the wash sits under the whole row.
|
||
struct SessionRowWash: View {
|
||
@EnvironmentObject var store: RemoteStore
|
||
let summary: WireSessionSummary
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@State private var pulsing = false
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color(.secondarySystemGroupedBackground) // the list row's normal surface
|
||
if summary.status.needsYou(summary.disposition) {
|
||
// Breathing amber (0.10 → 0.30 on a 1.3s ease, matching the Mac); a steady
|
||
// mid-tint under Reduce Motion.
|
||
Palette.attention.opacity(reduceMotion ? 0.18 : (pulsing ? 0.30 : 0.10))
|
||
.animation(
|
||
reduceMotion ? nil
|
||
: .easeInOut(duration: 1.3).repeatForever(autoreverses: true),
|
||
value: pulsing)
|
||
.onAppear { pulsing = true }
|
||
} else if store.unseenCompletion(summary) {
|
||
Palette.success.opacity(0.18)
|
||
}
|
||
}
|
||
}
|
||
}
|