| 1 | import SwiftUI |
| 2 | |
| 3 | // 🤖 CardView — the native renderer for the card schema (Card.swift; web twin |
| 4 | // web/static/cards.js renderCard). Shared leaf view, per-platform container: |
| 5 | // MessageBubble mounts it wherever a message carries a valid card, on iOS and |
| 6 | // Mac alike. Anything that reaches here already normalized — render never |
| 7 | // validates, so it can never disagree with the contract. |
| 8 | |
| 9 | public struct CardView: View { |
| 10 | public let card: Card |
| 11 | |
| 12 | public init(card: Card) { self.card = card } |
| 13 | |
| 14 | public var body: some View { |
| 15 | VStack(alignment: .leading, spacing: 8) { |
| 16 | ForEach(Array(card.blocks.enumerated()), id: \.offset) { _, block in |
| 17 | CardBlockView(block: block) |
| 18 | } |
| 19 | } |
| 20 | .padding(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14)) |
| 21 | .frame(maxWidth: 480, alignment: .leading) |
| 22 | .background( |
| 23 | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| 24 | .fill(Color.gray.opacity(0.08)) |
| 25 | ) |
| 26 | .overlay( |
| 27 | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| 28 | .strokeBorder(Color.gray.opacity(0.25), lineWidth: 1) |
| 29 | ) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | struct CardBlockView: View { |
| 34 | let block: CardBlock |
| 35 | |
| 36 | var body: some View { |
| 37 | switch block { |
| 38 | case .heading(let text, let sub): |
| 39 | VStack(alignment: .leading, spacing: 2) { |
| 40 | Text(text).font(.system(size: 16, weight: .bold)) |
| 41 | if let sub { |
| 42 | Text(sub).font(.system(size: 12)).foregroundStyle(.secondary) |
| 43 | } |
| 44 | } |
| 45 | case .text(let text): |
| 46 | Text(text).font(.system(size: 14)) |
| 47 | case .item(let text, let source, let url): |
| 48 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 49 | if let url, let dest = URL(string: url) { |
| 50 | Link(text, destination: dest) |
| 51 | .font(.system(size: 14)) |
| 52 | .frame(maxWidth: .infinity, alignment: .leading) |
| 53 | } else { |
| 54 | Text(text) |
| 55 | .font(.system(size: 14)) |
| 56 | .frame(maxWidth: .infinity, alignment: .leading) |
| 57 | } |
| 58 | if let source { |
| 59 | Text(source.uppercased()) |
| 60 | .font(.system(size: 10, weight: .medium)) |
| 61 | .kerning(0.4) |
| 62 | .foregroundStyle(.secondary) |
| 63 | } |
| 64 | } |
| 65 | case .list(let items): |
| 66 | VStack(alignment: .leading, spacing: 4) { |
| 67 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 68 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 69 | Text("•").foregroundStyle(.secondary) |
| 70 | Text(item).font(.system(size: 14)) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | case .kv(let rows): |
| 75 | Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 2) { |
| 76 | ForEach(Array(rows.enumerated()), id: \.offset) { _, row in |
| 77 | GridRow { |
| 78 | Text(row.key).font(.system(size: 13)).foregroundStyle(.secondary) |
| 79 | Text(row.value).font(.system(size: 13)).monospacedDigit() |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | case .divider: |
| 84 | Divider() |
| 85 | case .section(let title, let blocks): |
| 86 | VStack(alignment: .leading, spacing: 6) { |
| 87 | if let title { |
| 88 | Text(title.uppercased()) |
| 89 | .font(.system(size: 11, weight: .semibold)) |
| 90 | .kerning(0.5) |
| 91 | .foregroundStyle(.secondary) |
| 92 | } |
| 93 | ForEach(Array(blocks.enumerated()), id: \.offset) { _, inner in |
| 94 | CardBlockView(block: inner) |
| 95 | } |
| 96 | } |
| 97 | case .chips(let chips): |
| 98 | // Chips wrap like the web's flex row; a simple HStack suffices for |
| 99 | // the ≤4 chips the schema allows. |
| 100 | HStack(spacing: 8) { |
| 101 | ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in |
| 102 | if let dest = URL(string: chip.url) { |
| 103 | Link(chip.label, destination: dest) |
| 104 | .font(.system(size: 13)) |
| 105 | .padding(.horizontal, 12) |
| 106 | .padding(.vertical, 6) |
| 107 | .background(Capsule().strokeBorder(Color.gray.opacity(0.35), lineWidth: 1)) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | .padding(.top, 2) |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |