Files
agent-pet-companion/apps/macos/Sources/AgentPetCompanion/Views/SharedProductComponents.swift
T
xjxandgd-ops 1807246b7e Add portable skill management and harden protected delivery (#10)
## What changed

- add App and PetCore portable Agent Pet Maker skill management, with
fd-relative ownership and race-safe install/uninstall
- improve Pi no-model failure projection, session bubble message
selection, child-session filtering, onboarding rendering, and related
App behavior
- delete verified PR source branches with exact leases after merge while
dispatching exact-main CI first
- document direct/train multi-agent delivery, explicit
add/commit/clean/push checks, post-merge worktree audits, and exact-SHA
release proof

## Validation

- full `cargo test -p petcore`
- Swift package suite: 734 tests
- Pi connector privacy tests
- promotion/release Python tests and actionlint
- `./script/validate_pre_push.sh`
- independent specification and safety reviews: no remaining blockers

This PR changes the protected CI control plane, so it is intended for
full CI followed by trusted exact-head squash merge.

---------

Co-authored-by: gd-ops <gd-ops@xmfunny.com>
2026-08-12 01:49:51 +08:00

1444 lines
48 KiB
Swift

import AgentPetCompanionCore
import SwiftUI
/// Stable component names shared by the five control-center pages.
///
/// Page-specific presentation models provide the scope/instance identity. The
/// component type remains part of the identifier, so a page can compose the
/// full shared set without collisions.
enum SharedProductComponentKind: String, CaseIterable {
case pageHeader = "page-header"
case primaryExperienceCard = "primary-experience-card"
case petPreviewStage = "pet-preview-stage"
case agentHealthRow = "agent-health-row"
case attentionPresetPicker = "attention-preset-picker"
case advancedDetailsDisclosure = "advanced-details-disclosure"
case emptyStateAction = "empty-state-action"
case inlineRecoveryBanner = "inline-recovery-banner"
}
struct ProductComponentIdentity: Hashable {
let scope: String
let instance: String?
init(scope: String, instance: String? = nil) {
precondition(Self.isValidSegment(scope), "Invalid product component scope")
precondition(
instance.map(Self.isValidSegment) ?? true,
"Invalid product component instance"
)
self.scope = scope
self.instance = instance
}
func accessibilityIdentifier(
for kind: SharedProductComponentKind,
suffix: String? = nil
) -> String {
let segments = [
"product",
scope,
instance,
kind.rawValue,
suffix,
].compactMap { $0 }
return segments.joined(separator: ".")
}
private static func isValidSegment(_ value: String) -> Bool {
guard !value.isEmpty else { return false }
return value.unicodeScalars.allSatisfy { scalar in
switch scalar.value {
case 45, 48 ... 57, 65 ... 90, 95, 97 ... 122:
true
default:
false
}
}
}
}
enum SharedProductComponentLayout {
/// The detail column remains usable after the main navigation consumes its
/// width at the smallest supported control-center window size.
static let supportedMinimumContentWidth: CGFloat = 360
static let pageSpacing: CGFloat = 18
static let cardSpacing: CGFloat = 14
static let rowSpacing: CGFloat = 10
static let cardPadding: CGFloat = 20
static let compactPadding: CGFloat = 16
static let cornerRadius: CGFloat = 14
static let previewMinimumHeight: CGFloat = 220
}
/// Keeps the one-point rhythm formerly occupied by a separator without
/// drawing a line or exposing an accessibility element.
struct LayoutPreservingHorizontalSeparatorGap: View {
var body: some View {
Color.clear
.frame(height: 1)
.accessibilityHidden(true)
}
}
/// A closed visual vocabulary. Low-level check strings and arbitrary payloads
/// never decide status color, progress, or action authority.
enum ProductStatusAppearance: String, CaseIterable {
case neutral
case normal
case attention
case error
case checking
init(lifecycle: ProductLifecycleState) {
switch lifecycle {
case .idle, .done:
self = .normal
case .thinking, .tool:
self = .checking
case .waiting:
self = .attention
case .failed:
self = .error
}
}
init(connectionHealth: AgentConnectionHealthState) {
switch connectionHealth {
case .notChecked:
self = .neutral
case .checking:
self = .checking
case .connected:
self = .normal
case .needsRepair:
self = .attention
case .unavailable:
self = .error
}
}
}
struct ProductStatusPresentation: Hashable {
let appearance: ProductStatusAppearance
let title: String
let detail: String?
init(
appearance: ProductStatusAppearance,
title: String,
detail: String? = nil
) {
self.appearance = appearance
self.title = title
self.detail = detail
}
init(
lifecycle: ProductLifecycleState,
title: String,
detail: String? = nil
) {
self.init(
appearance: ProductStatusAppearance(lifecycle: lifecycle),
title: title,
detail: detail
)
}
init(
connectionHealth: AgentConnectionHealthState,
title: String,
detail: String? = nil
) {
self.init(
appearance: ProductStatusAppearance(connectionHealth: connectionHealth),
title: title,
detail: detail
)
}
}
/// Display metadata stays paired with the semantic action value. Views invoke
/// `action`, never infer a mutation from `title` or `accessibilityLabel`.
struct ProductActionPresentation<Action: Hashable>: Hashable {
let action: Action
let title: String
let systemImage: String?
let accessibilityLabel: String
let accessibilityHint: String?
let isEnabled: Bool
init(
action: Action,
title: String,
systemImage: String? = nil,
accessibilityLabel: String? = nil,
accessibilityHint: String? = nil,
isEnabled: Bool = true
) {
self.action = action
self.title = title
self.systemImage = systemImage
self.accessibilityLabel = accessibilityLabel ?? title
self.accessibilityHint = accessibilityHint
self.isEnabled = isEnabled
}
}
struct AttentionPresetOption: Identifiable, Hashable {
let preset: AttentionPreset
let title: String
let detail: String
let isSelectable: Bool
var id: AttentionPreset { preset }
init(
preset: AttentionPreset,
title: String,
detail: String,
isSelectable: Bool? = nil
) {
self.preset = preset
self.title = title
self.detail = detail
self.isSelectable = isSelectable ?? (preset != .custom)
}
}
enum SharedProductComponentText {
static func distinctDetail(
_ detail: String?,
comparedTo primaryValues: [String]
) -> String? {
guard let detail else { return nil }
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = trimmed.folding(
options: [.caseInsensitive, .diacriticInsensitive],
locale: .current
)
let repeatsPrimaryValue = primaryValues.contains { primaryValue in
primaryValue
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(
options: [.caseInsensitive, .diacriticInsensitive],
locale: .current
) == normalized
}
return repeatsPrimaryValue ? nil : trimmed
}
}
struct ProductPageHeader: View {
let identity: ProductComponentIdentity
let title: String
let summary: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.title2.weight(.bold))
.fixedSize(horizontal: false, vertical: true)
Text(summary)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .pageHeader)
)
}
}
struct PrimaryExperienceCard<Action: Hashable, Content: View>: View {
let identity: ProductComponentIdentity
let title: String
let summary: String
let status: ProductStatusPresentation?
let primaryAction: ProductActionPresentation<Action>?
let onPrimaryAction: (Action) -> Void
@ViewBuilder let content: Content
init(
identity: ProductComponentIdentity,
title: String,
summary: String,
status: ProductStatusPresentation? = nil,
primaryAction: ProductActionPresentation<Action>? = nil,
onPrimaryAction: @escaping (Action) -> Void,
@ViewBuilder content: () -> Content
) {
self.identity = identity
self.title = title
self.summary = summary
self.status = status
self.primaryAction = primaryAction
self.onPrimaryAction = onPrimaryAction
self.content = content()
}
var body: some View {
ProductCardSurface {
VStack(alignment: .leading, spacing: SharedProductComponentLayout.cardSpacing) {
headingAndAction
Text(summary)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let status {
ProductStatusIndicator(presentation: status)
}
content
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .primaryExperienceCard)
)
}
@ViewBuilder
private var headingAndAction: some View {
if let primaryAction {
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
heading
Spacer(minLength: 12)
ProductPrimaryActionButton(
presentation: primaryAction,
accessibilityIdentifier: identity.accessibilityIdentifier(
for: .primaryExperienceCard,
suffix: "primary-action"
),
perform: onPrimaryAction
)
}
VStack(alignment: .leading, spacing: 12) {
heading
ProductPrimaryActionButton(
presentation: primaryAction,
accessibilityIdentifier: identity.accessibilityIdentifier(
for: .primaryExperienceCard,
suffix: "primary-action"
),
perform: onPrimaryAction
)
}
}
} else {
heading
}
}
private var heading: some View {
Text(title)
.font(.title3.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
}
}
struct PetPreviewStage<Content: View>: View {
let identity: ProductComponentIdentity
let accessibilityLabel: String
let minimumHeight: CGFloat
@ViewBuilder let content: Content
init(
identity: ProductComponentIdentity,
accessibilityLabel: String,
minimumHeight: CGFloat = SharedProductComponentLayout.previewMinimumHeight,
@ViewBuilder content: () -> Content
) {
self.identity = identity
self.accessibilityLabel = accessibilityLabel
self.minimumHeight = minimumHeight
self.content = content()
}
var body: some View {
content
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: minimumHeight,
alignment: .center
)
.background(Color(nsColor: .textBackgroundColor), in: previewShape)
.clipShape(previewShape)
.overlay {
previewShape
.stroke(APCDesign.stroke.opacity(0.72), lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityElement(children: .contain)
.accessibilityLabel(accessibilityLabel)
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .petPreviewStage)
)
}
private var previewShape: RoundedRectangle {
RoundedRectangle(
cornerRadius: SharedProductComponentLayout.cornerRadius,
style: .continuous
)
}
}
struct AgentHealthRow: View {
let identity: ProductComponentIdentity
let agentTitle: String
let agentSummary: String?
let health: AgentConnectionHealthState
let healthTitle: String
let taskVerification: AgentTaskVerificationState?
let taskVerificationTitle: String?
let taskVerificationDetail: String?
let primaryAction: ProductActionPresentation<AgentConnectionPrimaryAction>?
let onPrimaryAction: (AgentConnectionPrimaryAction) -> Void
init(
identity: ProductComponentIdentity,
agentTitle: String,
agentSummary: String? = nil,
health: AgentConnectionHealthState,
healthTitle: String,
taskVerification: AgentTaskVerificationState? = nil,
taskVerificationTitle: String? = nil,
taskVerificationDetail: String? = nil,
primaryAction: ProductActionPresentation<AgentConnectionPrimaryAction>? = nil,
onPrimaryAction: @escaping (AgentConnectionPrimaryAction) -> Void
) {
self.identity = identity
self.agentTitle = agentTitle
self.agentSummary = agentSummary
self.health = health
self.healthTitle = healthTitle
self.taskVerification = taskVerification
self.taskVerificationTitle = taskVerificationTitle
self.taskVerificationDetail = taskVerificationDetail
self.primaryAction = primaryAction
self.onPrimaryAction = onPrimaryAction
}
var body: some View {
ProductCardSurface(padding: SharedProductComponentLayout.compactPadding) {
ViewThatFits(in: .horizontal) {
HStack(alignment: .center, spacing: SharedProductComponentLayout.rowSpacing) {
agentIdentity
Spacer(minLength: 12)
statusIndicators
actionButton
}
VStack(alignment: .leading, spacing: SharedProductComponentLayout.rowSpacing) {
agentIdentity
statusIndicators
actionButton
}
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .agentHealthRow)
)
}
private var agentIdentity: some View {
VStack(alignment: .leading, spacing: 3) {
Text(agentTitle)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
if let agentSummary {
Text(agentSummary)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var healthIndicator: some View {
ProductStatusIndicator(
presentation: ProductStatusPresentation(
connectionHealth: health,
title: healthTitle
)
)
}
private var statusIndicators: some View {
VStack(alignment: .leading, spacing: 6) {
healthIndicator
taskVerificationIndicator
}
}
@ViewBuilder
private var taskVerificationIndicator: some View {
if let taskVerification, let taskVerificationTitle {
HStack(alignment: .firstTextBaseline, spacing: 7) {
Image(systemName: taskVerification.systemImage)
.foregroundStyle(taskVerification.color)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(taskVerificationTitle)
.font(.callout.weight(.semibold))
.foregroundStyle(taskVerification.color)
.fixedSize(horizontal: false, vertical: true)
if let taskVerificationDetail {
Text(taskVerificationDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(taskVerificationTitle)
.accessibilityValue(taskVerificationDetail ?? "")
}
}
@ViewBuilder
private var actionButton: some View {
if let primaryAction,
primaryAction.action != .unavailable
{
ProductSecondaryActionButton(
presentation: primaryAction,
accessibilityIdentifier: identity.accessibilityIdentifier(
for: .agentHealthRow,
suffix: "primary-action"
),
perform: onPrimaryAction
)
}
}
}
extension AgentTaskVerificationState {
var color: Color {
switch self {
case .notRun:
APCDesign.textSecondary
case .awaitingTask:
APCDesign.warning
case .verified:
APCDesign.success
}
}
var systemImage: String {
switch self {
case .notRun:
"circle.dashed"
case .awaitingTask:
"clock.badge.questionmark"
case .verified:
"checkmark.seal.fill"
}
}
}
/// The single production session row used by the desktop conversation bubble.
///
/// Navigation authority and accessibility copy come from the same validated
/// `OverlaySessionContent`, so exact-session, Agent-host, and unavailable
/// destinations cannot drift into separate visual and assistive behaviors.
enum SessionBubbleRowPresentation {
case detailed
case standaloneSummary
}
/// A compact, closed status vocabulary for standalone session cards. Running
/// states stay visually neutral; only terminal or user-attention states add a
/// symbol, with no accessory button, capsule, or second card boundary.
enum SessionBubbleRowStateIndicator: String, Equatable {
case needsInput
case done
case failed
init?(eventType: AgentEventKind?) {
switch eventType {
case .waiting:
self = .needsInput
case .done:
self = .done
case .failed:
self = .failed
case .start, .thinking, .plan, .tool, nil:
return nil
}
}
var systemImage: String {
switch self {
case .needsInput: "exclamationmark.circle.fill"
case .done: "checkmark.circle.fill"
case .failed: "xmark.circle.fill"
}
}
var color: Color {
switch self {
case .needsInput: .orange
case .done: .green
case .failed: .red
}
}
}
struct SessionBubbleRow: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.overlayBubbleFontScale) private var fontScale
var session: OverlaySessionContent
var action: () -> Void
var dismissAction: (() -> Void)?
var presentation: SessionBubbleRowPresentation
var agentName: String?
var reservedTrailingAccessoryWidth: CGFloat
var primaryActionLabel: String?
@State private var hovered = false
@FocusState private var focused: Bool
init(
session: OverlaySessionContent,
action: @escaping () -> Void,
dismissAction: (() -> Void)? = nil,
presentation: SessionBubbleRowPresentation = .detailed,
agentName: String? = nil,
reservedTrailingAccessoryWidth: CGFloat = 0,
primaryActionLabel: String? = nil
) {
self.session = session
self.action = action
self.dismissAction = dismissAction
self.presentation = presentation
self.agentName = agentName
self.reservedTrailingAccessoryWidth = reservedTrailingAccessoryWidth
self.primaryActionLabel = primaryActionLabel
}
var body: some View {
Button(action: action) {
rowContent
}
.buttonStyle(.plain)
.focused($focused)
.onHover { hovered = $0 }
.accessibilityElement(children: .ignore)
.accessibilityIdentifier("overlay.session.\(session.id)")
.accessibilityLabel(session.accessibilityLabel)
.modifier(SessionBubbleAccessibilityActions(
openLabel: primaryActionLabel ?? session.actionLabel,
closeLabel: dismissAction == nil
? nil
: APCLocalization.text(.overlayDismissSession),
onOpen: action,
onClose: dismissAction
))
.help(helpText)
}
private var rowContent: some View {
Group {
switch presentation {
case .detailed:
detailedTextContent
case .standaloneSummary:
standaloneSummaryContent
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, OverlayGeometry.bubbleSessionHorizontalPadding)
.padding(.vertical, OverlayGeometry.bubbleSessionVerticalPadding)
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background {
switch presentation {
case .detailed:
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill((statusColor ?? .clear).opacity(0.12))
if hovered || focused {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.primary.opacity(0.05))
}
}
case .standaloneSummary:
// The card already owns one full glass surface. Keeping this
// row transparent avoids a second rounded status/hover layer
// that reads as nested borders in the flat session tray.
Color.clear
}
}
}
private var standaloneSummaryContent: some View {
VStack(alignment: .leading, spacing: OverlayGeometry.bubbleStandaloneMetadataSpacing) {
standaloneIdentityLine
.padding(.trailing, reservedTrailingAccessoryWidth)
standaloneSummaryText
.lineLimit(
OverlayGeometry.bubbleStandaloneSummaryLineLimit,
reservesSpace: true
)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(
height: OverlayGeometry.bubbleStandaloneSummaryTextHeight(
fontScale: fontScale
),
alignment: .topLeading
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var standaloneIdentityLine: some View {
HStack(alignment: .center, spacing: 5) {
if let source = session.source {
AgentIconView(
source: source,
size: OverlayGeometry.bubbleHeaderAvatarWidth
)
.accessibilityHidden(true)
}
if let agentName = resolvedStandaloneAgentName {
Text(agentName)
.font(OverlayBubbleTypography.font(
.caption1,
weight: .semibold,
scale: fontScale
))
.foregroundStyle(Color.secondary)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(1)
}
if let surfaceLabel = session.surfaceLabel {
Text("· \(surfaceLabel)")
.font(OverlayBubbleTypography.font(
.caption2,
weight: .medium,
scale: fontScale
))
.foregroundStyle(Color.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
Spacer(minLength: 8)
if !session.statusText.isEmpty {
HStack(spacing: 3) {
if let stateIndicator {
Image(systemName: stateIndicator.systemImage)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(stateIndicator.color)
}
Text(session.statusText)
.foregroundStyle(Color.primary)
}
.font(OverlayBubbleTypography.font(
.caption2,
weight: .semibold,
scale: fontScale
))
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(
Capsule()
.fill((statusColor ?? .clear).opacity(0.24))
)
.overlay {
Capsule()
.stroke((statusColor ?? .clear).opacity(0.62), lineWidth: 0.75)
.allowsHitTesting(false)
}
.help(session.statusText)
.accessibilityHidden(true)
.accessibilityIdentifier(standaloneStatusIdentifier)
}
}
.frame(
maxWidth: .infinity,
minHeight: OverlayGeometry.bubbleStandaloneMetadataHeight(
fontScale: fontScale
),
maxHeight: OverlayGeometry.bubbleStandaloneMetadataHeight(
fontScale: fontScale
),
alignment: .leading
)
}
private var resolvedStandaloneAgentName: String? {
let resolved = (agentName ?? session.source?.title)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return resolved?.isEmpty == false ? resolved : nil
}
private var standaloneStatusIdentifier: String {
"overlay.session.status.\(stateIndicator?.rawValue ?? "running")"
}
private var standaloneSummaryText: Text {
let title = Text(session.sessionTitle)
.font(OverlayBubbleTypography.font(
.callout,
weight: .semibold,
scale: fontScale
))
.foregroundColor(.primary)
guard !session.standaloneSummaryText.isEmpty else { return title }
return title
+ Text(" · ")
.font(OverlayBubbleTypography.font(
.callout,
weight: .medium,
scale: fontScale
))
.foregroundColor(.secondary)
+ Text(session.standaloneSummaryText)
.font(OverlayBubbleTypography.font(
.callout,
weight: .regular,
scale: fontScale
))
.foregroundColor(.secondary)
}
private var detailedTextContent: some View {
VStack(alignment: .leading, spacing: OverlayGeometry.bubbleSessionTitleSpacing) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(session.sessionTitle)
.font(OverlayBubbleTypography.font(
.callout,
weight: .semibold,
scale: fontScale
))
.foregroundStyle(Color.primary)
.lineLimit(1)
.truncationMode(.tail)
if let surfaceLabel = session.surfaceLabel {
Text(surfaceLabel)
.font(OverlayBubbleTypography.font(
.caption2,
weight: .medium,
scale: fontScale
))
.foregroundStyle(Color.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(
Capsule()
.fill(Color.secondary.opacity(0.12))
)
}
Spacer(minLength: 8)
if !session.statusText.isEmpty {
Text(session.statusText)
.font(OverlayBubbleTypography.font(
.caption2,
weight: .semibold,
scale: fontScale
))
.foregroundStyle(Color.primary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(
Capsule()
.fill((statusColor ?? .clear).opacity(0.24))
)
.overlay {
Capsule()
.stroke((statusColor ?? .clear).opacity(0.62), lineWidth: 0.75)
.allowsHitTesting(false)
}
}
destinationIndicator
}
.frame(
height: OverlayGeometry.bubbleDetailedHeaderHeight(fontScale: fontScale),
alignment: .top
)
Text(session.primaryDetailText)
.font(OverlayBubbleTypography.font(
.caption1,
weight: .semibold,
scale: fontScale
))
.foregroundStyle(Color.primary)
.lineLimit(OverlayGeometry.bubbleDetailLineLimit, reservesSpace: true)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(
height: OverlayGeometry.bubbleDetailTextHeight(fontScale: fontScale),
alignment: .topLeading
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// Every row reserves one trailing affordance slot. Hover/focus reveals
/// either the destination arrow or an explicit unavailable indicator.
private var destinationIndicator: some View {
Image(systemName: session.canOpen
? "arrow.up.forward"
: "exclamationmark.circle")
.font(OverlayBubbleTypography.font(
.caption2,
weight: .bold,
scale: fontScale
))
.foregroundStyle(session.canOpen ? Color.primary : Color.secondary)
.frame(width: OverlayBubbleTypography.scaledControlMetric(
9,
scale: fontScale
))
.opacity(hovered || focused ? 1 : 0)
.animation(
reduceMotion
? nil
: .easeOut(duration: OverlayMotion.controlFadeDuration),
value: hovered || focused
)
.accessibilityHidden(true)
}
private var helpText: String {
if let primaryActionLabel {
return primaryActionLabel
}
guard session.canOpen else {
return APCLocalization.text(.overlayHelpUnavailable)
}
if session.dismissesAfterActivation {
return APCLocalization.text(.overlayHelpOpenAndDismiss)
}
return APCLocalization.text(.overlayHelpOpen)
}
private var statusColor: Color? {
SessionBubbleRowStateIndicator(eventType: session.eventType)?.color
}
private var stateIndicator: SessionBubbleRowStateIndicator? {
SessionBubbleRowStateIndicator(eventType: session.eventType)
}
}
private struct SessionBubbleAccessibilityActions: ViewModifier {
var openLabel: String?
var closeLabel: String?
var onOpen: () -> Void
var onClose: (() -> Void)?
@ViewBuilder
func body(content: Content) -> some View {
if let openLabel, let closeLabel, let onClose {
content
.accessibilityAction(named: openLabel) { onOpen() }
.accessibilityAction(named: closeLabel) { onClose() }
} else if let openLabel {
content.accessibilityAction(named: openLabel) { onOpen() }
} else if let closeLabel, let onClose {
content.accessibilityAction(named: closeLabel) { onClose() }
} else {
content
}
}
}
struct AttentionPresetPicker: View {
let identity: ProductComponentIdentity
let title: String
let selection: AttentionPreset
let options: [AttentionPresetOption]
let onSelection: (AttentionPreset) -> Void
init(
identity: ProductComponentIdentity,
title: String,
selection: AttentionPreset,
options: [AttentionPresetOption],
onSelection: @escaping (AttentionPreset) -> Void
) {
precondition(
Set(options.map(\.preset)).count == options.count,
"Attention preset options must be unique"
)
self.identity = identity
self.title = title
self.selection = selection
self.options = options
self.onSelection = onSelection
}
var body: some View {
ProductCardSurface(padding: SharedProductComponentLayout.compactPadding) {
VStack(alignment: .leading, spacing: SharedProductComponentLayout.rowSpacing) {
Picker(title, selection: selectionBinding) {
ForEach(options) { option in
Text(option.title)
.fixedSize(horizontal: false, vertical: true)
.tag(option.preset)
.disabled(!option.isSelectable)
.accessibilityIdentifier(
identity.accessibilityIdentifier(
for: .attentionPresetPicker,
suffix: "option-\(option.preset.rawValue)"
)
)
}
}
.pickerStyle(.radioGroup)
if let selectedOption {
Text(selectedOption.detail)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .attentionPresetPicker)
)
}
private var selectedOption: AttentionPresetOption? {
options.first { $0.preset == selection }
}
private var selectionBinding: Binding<AttentionPreset> {
Binding(
get: { selection },
set: { nextSelection in
guard options.first(where: { $0.preset == nextSelection })?.isSelectable == true
else { return }
onSelection(nextSelection)
}
)
}
}
struct AdvancedDetailsDisclosure<Content: View>: View {
let identity: ProductComponentIdentity
let title: String
let summary: String?
@Binding var isExpanded: Bool
@ViewBuilder let content: Content
@State private var isHeaderHovered = false
@FocusState private var isHeaderFocused: Bool
init(
identity: ProductComponentIdentity,
title: String,
summary: String? = nil,
isExpanded: Binding<Bool>,
@ViewBuilder content: () -> Content
) {
self.identity = identity
self.title = title
self.summary = summary
self._isExpanded = isExpanded
self.content = content()
}
var body: some View {
ProductCardSurface(padding: SharedProductComponentLayout.compactPadding) {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.16)) {
isExpanded.toggle()
}
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.frame(width: 12, height: 20)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
if let summary {
Text(summary)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background {
RoundedRectangle(
cornerRadius: SharedProductComponentLayout.cornerRadius - 4,
style: .continuous
)
.fill(
Color.primary.opacity(
isHeaderHovered || isHeaderFocused ? 0.05 : 0
)
)
.allowsHitTesting(false)
}
.focused($isHeaderFocused)
.onHover { isHeaderHovered = $0 }
.animation(.easeOut(duration: 0.12), value: isHeaderHovered)
.animation(.easeOut(duration: 0.12), value: isHeaderFocused)
.accessibilityLabel(title)
.accessibilityValue(APCLocalization.text(
isExpanded ? .commonExpanded : .commonCollapsed
))
.accessibilityHint(APCLocalization.text(
isExpanded
? .commonCollapseDisclosureHint
: .commonExpandDisclosureHint
))
if isExpanded {
Divider()
.padding(.top, 10)
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 10)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .advancedDetailsDisclosure)
)
}
}
struct EmptyStateAction<Action: Hashable>: View {
let identity: ProductComponentIdentity
let status: ProductStatusPresentation
let message: String
let primaryAction: ProductActionPresentation<Action>?
let onPrimaryAction: (Action) -> Void
init(
identity: ProductComponentIdentity,
status: ProductStatusPresentation,
message: String,
primaryAction: ProductActionPresentation<Action>? = nil,
onPrimaryAction: @escaping (Action) -> Void
) {
self.identity = identity
self.status = status
self.message = message
self.primaryAction = primaryAction
self.onPrimaryAction = onPrimaryAction
}
var body: some View {
ProductCardSurface {
VStack(alignment: .center, spacing: SharedProductComponentLayout.cardSpacing) {
ProductStatusIndicator(presentation: status)
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if let primaryAction {
ProductPrimaryActionButton(
presentation: primaryAction,
accessibilityIdentifier: identity.accessibilityIdentifier(
for: .emptyStateAction,
suffix: "primary-action"
),
perform: onPrimaryAction
)
}
}
.frame(maxWidth: .infinity)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .emptyStateAction)
)
}
}
struct InlineRecoveryBanner<Action: Hashable>: View {
let identity: ProductComponentIdentity
let status: ProductStatusPresentation
let primaryAction: ProductActionPresentation<Action>?
let onPrimaryAction: (Action) -> Void
init(
identity: ProductComponentIdentity,
status: ProductStatusPresentation,
primaryAction: ProductActionPresentation<Action>? = nil,
onPrimaryAction: @escaping (Action) -> Void
) {
self.identity = identity
self.status = status
self.primaryAction = primaryAction
self.onPrimaryAction = onPrimaryAction
}
var body: some View {
ViewThatFits(in: .horizontal) {
HStack(alignment: .center, spacing: SharedProductComponentLayout.rowSpacing) {
ProductStatusIndicator(presentation: status)
Spacer(minLength: 12)
actionButton
}
VStack(alignment: .leading, spacing: SharedProductComponentLayout.rowSpacing) {
ProductStatusIndicator(presentation: status)
actionButton
}
}
.padding(SharedProductComponentLayout.compactPadding)
.background(status.appearance.color.opacity(0.08), in: bannerShape)
.overlay {
bannerShape
.stroke(status.appearance.color.opacity(0.34), lineWidth: 1)
.allowsHitTesting(false)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier(
identity.accessibilityIdentifier(for: .inlineRecoveryBanner)
)
}
@ViewBuilder
private var actionButton: some View {
if let primaryAction {
ProductPrimaryActionButton(
presentation: primaryAction,
accessibilityIdentifier: identity.accessibilityIdentifier(
for: .inlineRecoveryBanner,
suffix: "primary-action"
),
perform: onPrimaryAction
)
}
}
private var bannerShape: RoundedRectangle {
RoundedRectangle(
cornerRadius: SharedProductComponentLayout.cornerRadius,
style: .continuous
)
}
}
struct ProductActionLabel: View {
let title: String
let systemImage: String?
var body: some View {
HStack(alignment: .center, spacing: 6) {
if let systemImage {
Image(systemName: systemImage)
.accessibilityHidden(true)
}
Text(title)
.fixedSize(horizontal: false, vertical: true)
}
.font(.callout.weight(.semibold))
.multilineTextAlignment(.center)
}
}
private struct ProductPrimaryActionButton<Action: Hashable>: View {
let presentation: ProductActionPresentation<Action>
let accessibilityIdentifier: String
let perform: (Action) -> Void
var body: some View {
Button {
perform(presentation.action)
} label: {
ProductActionLabel(
title: presentation.title,
systemImage: presentation.systemImage
)
}
.apcClearGlassButtonStyle(prominent: true)
.controlSize(.regular)
.disabled(!presentation.isEnabled)
.accessibilityLabel(presentation.accessibilityLabel)
.accessibilityHint(presentation.accessibilityHint ?? "")
.accessibilityIdentifier(accessibilityIdentifier)
}
}
private struct ProductSecondaryActionButton<Action: Hashable>: View {
let presentation: ProductActionPresentation<Action>
let accessibilityIdentifier: String
let perform: (Action) -> Void
var body: some View {
Button {
perform(presentation.action)
} label: {
ProductActionLabel(
title: presentation.title,
systemImage: presentation.systemImage
)
}
.apcClearGlassButtonStyle()
.controlSize(.regular)
.disabled(!presentation.isEnabled)
.accessibilityLabel(presentation.accessibilityLabel)
.accessibilityHint(presentation.accessibilityHint ?? "")
.accessibilityIdentifier(accessibilityIdentifier)
}
}
struct ProductStatusIndicator: View {
let presentation: ProductStatusPresentation
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 7) {
if presentation.appearance == .checking {
ProgressView()
.controlSize(.small)
.accessibilityHidden(true)
} else {
Image(systemName: presentation.appearance.systemImage)
.foregroundStyle(presentation.appearance.color)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
Text(presentation.title)
.font(.callout.weight(.semibold))
.foregroundStyle(presentation.appearance.color)
.fixedSize(horizontal: false, vertical: true)
if let detail = presentation.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(presentation.title)
.accessibilityValue(presentation.detail ?? "")
}
}
struct ProductCardSurface<Content: View>: View {
@Environment(\.colorSchemeContrast) private var colorSchemeContrast
let padding: CGFloat
@ViewBuilder let content: Content
init(
padding: CGFloat = SharedProductComponentLayout.cardPadding,
@ViewBuilder content: () -> Content
) {
self.padding = padding
self.content = content()
}
var body: some View {
content
.padding(padding)
.background(APCDesign.panel, in: shape)
.overlay {
shape
.stroke(
APCDesign.stroke.opacity(
colorSchemeContrast == .increased ? 1 : 0.72
),
lineWidth: colorSchemeContrast == .increased ? 2 : 1
)
.allowsHitTesting(false)
}
}
private var shape: RoundedRectangle {
RoundedRectangle(
cornerRadius: SharedProductComponentLayout.cornerRadius,
style: .continuous
)
}
}
private extension ProductStatusAppearance {
var color: Color {
switch self {
case .neutral:
APCDesign.textSecondary
case .normal:
APCDesign.textSecondary
case .attention:
APCDesign.warning
case .error:
APCDesign.destructive
case .checking:
APCDesign.accent
}
}
var systemImage: String {
switch self {
case .neutral:
"circle.dashed"
case .normal:
"checkmark.circle"
case .attention:
"exclamationmark.circle.fill"
case .error:
"xmark.octagon.fill"
case .checking:
"hourglass"
}
}
}