import ActivityKit import SwiftUI import WidgetKit /// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3). /// The glance answers "what is Nucleic doing right now?": headline counts (running / waiting / /// approvals), the total churn in flight, and the most urgent sessions with their state and diff. /// Tapping opens the app (straight to the approval, via the notification-style route). /// /// Self-contained colors/glyphs — the extension doesn't link the app's `Theme`, so the small /// palette + glyph maps it needs live here (the same teal/amber/blue/green as `Palette`). struct SessionLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: NucleicSessionAttributes.self) { context in LockScreenView(state: context.state, hostName: context.attributes.hostName) .widgetURL(context.state.tapURL) // When something needs the user, wash the whole card in the attention tint; // otherwise the calm dark background (see the border/header cues in LockScreenView). .activityBackgroundTint(context.state.needsAttention ? ActivityPalette.attentionTint(context.state).opacity(0.28) : Color.black.opacity(0.55)) .activitySystemActionForegroundColor(.white) } dynamicIsland: { context in let state = context.state return DynamicIsland { DynamicIslandExpandedRegion(.leading) { CountBadge( systemImage: "circle.lefthalf.filled", count: state.runningCount, tint: ActivityPalette.active) } DynamicIslandExpandedRegion(.trailing) { if state.approvalCount > 0 { CountBadge( systemImage: "exclamationmark.shield.fill", count: state.approvalCount, tint: ActivityPalette.attention) } else if state.needsYouCount > 0 { CountBadge( systemImage: "bubble.left.fill", count: state.needsYouCount, tint: ActivityPalette.accent) } else { Label("0", systemImage: "checkmark.circle") .font(.headline).foregroundStyle(.secondary) } } DynamicIslandExpandedRegion(.bottom) { VStack(alignment: .leading, spacing: 5) { ForEach(state.lines.prefix(2)) { line in SessionRow(line: line) } } } } compactLeading: { // Calm state shows the Nucleic Control brand mark (the purple atom); when the // user is needed it switches to the amber approval glyph. Image(systemName: state.needsAttention ? ActivityPalette.glyph(.approval) : "atom") .foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.brand) } compactTrailing: { Text("\(state.needsAttention ? max(state.approvalCount, state.needsYouCount) : state.runningCount)") .font(.caption2.bold()) .foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active) } minimal: { Image(systemName: state.needsAttention ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) .foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active) } // The compact/minimal presentations tap through to the same target as the lock // screen: a waiting session opens itself; otherwise the sessions list. .widgetURL(state.tapURL) } } } // MARK: - Lock screen private struct LockScreenView: View { let state: NucleicSessionAttributes.ContentState let hostName: String var body: some View { let attention = state.needsAttention let tint = ActivityPalette.attentionTint(state) return VStack(alignment: .leading, spacing: 8) { // Header: brand + host, and the headline counts. When the user is needed the brand // mark becomes the attention glyph in its tint, so the header itself reads "act now". HStack(spacing: 8) { Image(systemName: attention ? ActivityPalette.glyph(state.approvalCount > 0 ? .approval : .needsInput) : "hexagon.fill") .font(.footnote) .foregroundStyle(attention ? tint : ActivityPalette.active) Text(hostName) .font(.caption.weight(.semibold)) .lineLimit(1) Spacer(minLength: 8) CountChips(state: state) } // The most urgent sessions — what's actually happening. ForEach(state.lines.prefix(3)) { line in SessionRow(line: line) } } .padding(14) .foregroundStyle(.white) // Attention theme: a soft tinted wash + a colored border around the whole glance, so a // session waiting on the user is unmistakable at a glance even before reading the rows. .background { if attention { RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(tint.opacity(0.12)) } } .overlay { if attention { RoundedRectangle(cornerRadius: 22, style: .continuous) .strokeBorder(tint.opacity(0.9), lineWidth: 2) } } } } // MARK: - Shared pieces /// One session: state glyph + title + "project · Backend" + a right-aligned status/diff. /// /// Each row is its own tap target: wrapped in a `Link` to `nucleic://session/`, so tapping a /// specific session on the lock screen or expanded Dynamic Island opens *that* session — overriding /// the activity-wide `widgetURL` (which still governs the header, churn footer, and empty space, and /// remains the only tap target the compact/minimal presentations can offer). private struct SessionRow: View { let line: NucleicSessionAttributes.SessionLine /// An approval this row can resolve inline: it's blocked on an approval, carries that approval's /// id, and isn't high-risk (§3.3 — high-risk keeps the plain tap-to-open row and is answered on /// the app's guarded card). `nil` until the producer populates `approvalID` — an older host, or /// today's summary that doesn't yet carry it, so the row stays a deep-link exactly as before. private var inlineApprovalID: String? { guard line.kind == .approval, (line.approvalIsHighRisk ?? false) == false else { return nil } return line.approvalID } var body: some View { if let approvalID = inlineApprovalID { actionableRow(approvalID: approvalID) } else if let url = NucleicDeepLink.session(line.id) { Link(destination: url) { rowContent } } else { rowContent } } /// An approval row with inline Allow/Deny. The identity still deep-links to the session; the /// buttons fire `ApproveFromActivityIntent`, which the system performs in the app's background /// process — allow/deny without unlocking (§4.1). private func actionableRow(approvalID: String) -> some View { HStack(spacing: 8) { if let url = NucleicDeepLink.session(line.id) { Link(destination: url) { rowIdentity } } else { rowIdentity } Spacer(minLength: 6) ApprovalButtons(sessionID: line.id, approvalID: approvalID) } } /// The glyph + title + "project · Backend" — the row's identity, minus the trailing status. private var rowIdentity: some View { HStack(spacing: 8) { Image(systemName: ActivityPalette.glyph(line.kind)) .font(.footnote) .foregroundStyle(ActivityPalette.color(line.kind)) .frame(width: 16) VStack(alignment: .leading, spacing: 1) { Text(line.title) .font(.subheadline.weight(.medium)) .lineLimit(1) HStack(spacing: 4) { Text(line.project) Text("·").foregroundStyle(.secondary) Text(line.backend.label) .foregroundStyle(ActivityPalette.backendColor(line.backend)) } .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } } private var rowContent: some View { HStack(spacing: 8) { rowIdentity Spacer(minLength: 6) Text(line.detail) .font(.caption2.weight(.medium)) .monospacedDigit() .foregroundStyle(ActivityPalette.color(line.kind)) .lineLimit(1) } } } /// The compact inline Allow/Deny pair for an actionable approval row. Each button fires /// `ApproveFromActivityIntent` (a low/medium-risk decision — high-risk never reaches here). private struct ApprovalButtons: View { let sessionID: String let approvalID: String var body: some View { HStack(spacing: 6) { Button(intent: intent(allow: false)) { Image(systemName: "xmark").font(.caption2.bold()) } .tint(ActivityPalette.danger) Button(intent: intent(allow: true)) { Image(systemName: "checkmark").font(.caption2.bold()) } .tint(ActivityPalette.success) } .buttonStyle(.borderedProminent) .controlSize(.small) } private func intent(allow: Bool) -> ApproveFromActivityIntent { ApproveFromActivityIntent( approvalID: approvalID, sessionID: sessionID, isHighRisk: false, allow: allow) } } /// Headline count chips: running (blue), waiting (teal), approvals (amber). Only nonzero show. private struct CountChips: View { let state: NucleicSessionAttributes.ContentState var body: some View { HStack(spacing: 10) { if state.runningCount > 0 { chip("circle.lefthalf.filled", state.runningCount, ActivityPalette.active) } if state.needsYouCount > 0 { chip("bubble.left.fill", state.needsYouCount, ActivityPalette.accent) } if state.approvalCount > 0 { chip("exclamationmark.shield.fill", state.approvalCount, ActivityPalette.attention) } } } private func chip(_ systemImage: String, _ count: Int, _ tint: Color) -> some View { HStack(spacing: 3) { Image(systemName: systemImage).font(.caption2) Text("\(count)").font(.caption.weight(.semibold)).monospacedDigit() } .foregroundStyle(tint) } } /// Dynamic Island expanded leading/trailing count badge. private struct CountBadge: View { let systemImage: String let count: Int let tint: Color var body: some View { Label("\(count)", systemImage: systemImage) .font(.headline) .monospacedDigit() .foregroundStyle(tint) } } /// The categorical colors + glyphs the Activity needs — the same semantics as the app's /// `Palette.status` / `StatusStyle.symbol`, re-declared because the widget extension can't link /// the app's `Theme`. private enum ActivityPalette { static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // approval amber static let accent = Color(red: 0.16, green: 0.72, blue: 0.68) // needs-input teal static let success = Color(red: 0.30, green: 0.78, blue: 0.45) // done green static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // error red static let neutral = Color.secondary static let added = Color(red: 0.30, green: 0.78, blue: 0.45) static let removed = Color(red: 0.92, green: 0.34, blue: 0.34) /// The color the "attention needed" theme paints with: approval amber is loudest, else the /// needs-input teal. Only meaningful when `state.needsAttention` — callers gate on that. static func attentionTint(_ state: NucleicSessionAttributes.ContentState) -> Color { state.approvalCount > 0 ? attention : accent } static func glyph(_ kind: NucleicSessionAttributes.Kind) -> String { switch kind { case .running: "circle.lefthalf.filled" case .provisioning: "circle.dotted" case .approval: "exclamationmark.triangle.fill" case .needsInput: "bubble.left.fill" case .done: "checkmark.circle.fill" case .error: "xmark.octagon.fill" case .idle: "circle" } } static func color(_ kind: NucleicSessionAttributes.Kind) -> Color { switch kind { case .running: active case .provisioning: neutral case .approval: attention case .needsInput: accent case .done: success case .error: danger case .idle: neutral } } static func backendColor(_ backend: NucleicSessionAttributes.Backend) -> Color { switch backend { case .claude: Color(red: 0.85, green: 0.52, blue: 0.30) // Anthropic clay case .codex: Color(red: 0.45, green: 0.72, blue: 0.55) // OpenAI green case .grok: Color(red: 0.55, green: 0.58, blue: 0.66) // xAI graphite case .other: Color.secondary } } }