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: { Image(systemName: state.needsAttention ? ActivityPalette.glyph(.approval) : ActivityPalette.glyph(.running)) .foregroundStyle(state.needsAttention ? ActivityPalette.attention : ActivityPalette.active) } 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 { VStack(alignment: .leading, spacing: 8) { // Header: brand + host, and the headline counts. HStack(spacing: 8) { Image(systemName: "hexagon.fill") .font(.footnote) .foregroundStyle(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) } } // 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 var body: some View { if let url = NucleicDeepLink.session(line.id) { Link(destination: url) { rowContent } } else { rowContent } } private var rowContent: 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) } Spacer(minLength: 6) Text(line.detail) .font(.caption2.weight(.medium)) .monospacedDigit() .foregroundStyle(ActivityPalette.color(line.kind)) .lineLimit(1) } } } /// 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) 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 } } }