Nucleic-Session: 56C5F781-4444-466B-91CE-A7734A8E117F Co-authored-by: Nucleic <[email protected]>
325 lines
14 KiB
Swift
325 lines
14 KiB
Swift
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 {
|
|
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/<id>`, 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
|
|
}
|
|
}
|
|
}
|