Files
nucleic-remote-ios/NucleicRemote/NucleicRemoteWidgets/SessionLiveActivity.swift
T

267 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
.activityBackgroundTint(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)
}
if state.hasChurn {
ChurnLine(state: state).padding(.top, 1)
}
}
}
} 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)
}
// Aggregate churn footer — how much work is in flight right now.
if state.hasChurn {
Divider().overlay(Color.white.opacity(0.12))
ChurnLine(state: state)
}
}
.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/<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
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)
}
}
/// Aggregate worktree churn: "N files +A −R".
private struct ChurnLine: View {
let state: NucleicSessionAttributes.ContentState
var body: some View {
HStack(spacing: 6) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundStyle(.secondary)
Text("\(state.filesChanged) file\(state.filesChanged == 1 ? "" : "s") changed")
.foregroundStyle(.secondary)
Text("+\(state.linesAdded)")
.foregroundStyle(ActivityPalette.added)
Text("−\(state.linesRemoved)")
.foregroundStyle(ActivityPalette.removed)
}
.font(.caption2.weight(.medium))
.monospacedDigit()
.lineLimit(1)
}
}
/// 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
}
}
}