The read view's inline-only AttributedString shim gives way to a real block renderer over BodyMarkup — the same parse the Mac Preview trusts, whose swift-markdown dependency project.yml said was already riding along for exactly this surface. Headings, nested lists with static task checkboxes, quotes, GFM tables with per-column alignment and clamped colspans, sideways-scrolling code blocks, literal HTML, dividers, and tappable absolute links (relative ones stay prose — no folder to resolve against). The title block sheds its inner padding so its leading edge sits flush with the body; the tint now grows outward via a negative background inset instead of pushing the text in. One rich fixture card body enriched to exercise every block kind; Mac fixture round-trip suites verified green against it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
144 lines
6.1 KiB
Swift
144 lines
6.1 KiB
Swift
import SwiftUI
|
|
|
|
/// The card's read-only view — `BoardRoute.card`'s destination.
|
|
///
|
|
/// Holds board-relative IDs only, never a `Card` value: every body evaluation re-reads the card
|
|
/// from `session.snapshot`, so a write this screen's own edit sheet makes (or one that lands from
|
|
/// elsewhere while it's open) is reflected the moment the session's reload lands, and a card
|
|
/// trashed on another device is noticed rather than shown stale.
|
|
///
|
|
/// **Read-only, by design.** There is no draft here and nothing this screen ever writes — editing
|
|
/// lives entirely in `CardEditScreen`, presented full-screen from the Edit button. That split is
|
|
/// the whole point of the transactional model: a screen that only ever renders `session.snapshot`
|
|
/// cannot itself go stale relative to disk, and there is no auto-save timing to reason about
|
|
/// because there is no save at all on this side of the Edit button.
|
|
struct CardDetailScreen: View {
|
|
let boardRoot: URL
|
|
let laneID: ItemID
|
|
let cardID: ItemID
|
|
|
|
@Environment(BoardIndexStore.self) private var index
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var isPresentingEdit = false
|
|
|
|
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
|
|
|
private var card: Card? {
|
|
session.snapshot?.lanes.first { $0.id == laneID }?.cards.first { $0.id == cardID }
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
.navigationTitle("Card")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
if card != nil {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button("Edit") { isPresentingEdit = true }
|
|
}
|
|
}
|
|
}
|
|
.task { session.open() }
|
|
.fullScreenCover(isPresented: $isPresentingEdit) {
|
|
CardEditScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if let card {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
if session.lastError != nil {
|
|
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
titleBlock(for: card)
|
|
bodyBlock(for: card)
|
|
detailsFooter(for: card)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
} else if case .ready = session.phase {
|
|
ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted."))
|
|
.task { dismiss() }
|
|
} else {
|
|
ProgressView("Opening card")
|
|
}
|
|
}
|
|
|
|
/// The title, its icon when the card has one, and a subtle background tint lifted from the
|
|
/// card's own `background` swatch — passive display only, no swatches to tap here. An
|
|
/// unrecognized colour name (the same leniency `ItemIconView`/`CardPalette` document for
|
|
/// themselves) just skips the tint rather than guessing at one.
|
|
///
|
|
/// **Alignment constraint.** The title's leading edge has to land exactly where the body's
|
|
/// paragraphs land, one block below — both sit at nothing but the `ScrollView`'s own outer
|
|
/// `.padding()`, so this view carries no padding of its own around the icon/text content. The
|
|
/// tint still wants breathing room rather than painting flush against the letterforms, but
|
|
/// that room can't come from padding the content — that's exactly what would reintroduce the
|
|
/// indent this fixes. It comes from the tint shape growing *outward* past the content's own
|
|
/// bounds instead, via a negative inset in `.background`, which is invisible to layout: the
|
|
/// row's frame — and so the title's leading edge — is unaffected by how far its background
|
|
/// paints past it. A card with no tint (most of them) renders with zero indent, flush with the
|
|
/// body below; a tinted card's title sits in exactly the same place, just with a soft wash
|
|
/// bleeding past its edges.
|
|
@ViewBuilder
|
|
private func titleBlock(for card: Card) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
|
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
|
|
titleText(for: card)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
if let tintColor = tintColor(for: card) {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(tintColor.opacity(0.15))
|
|
.padding(-10)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func titleText(for card: Card) -> some View {
|
|
if let title = card.title.value, !title.isEmpty {
|
|
Text(title)
|
|
.font(.title.bold())
|
|
} else {
|
|
Text("Untitled Card")
|
|
.font(.title.bold())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func tintColor(for card: Card) -> Color? {
|
|
card.background.value.flatMap { CardPalette.color(named: $0, in: CardPalette.backgrounds) }
|
|
}
|
|
|
|
/// The body, rendered as Markdown — see `CardBodyView` for the block-by-block renderer this
|
|
/// delegates to, built on the same `BodyMarkup` model the Mac's Preview surface parses with.
|
|
@ViewBuilder
|
|
private func bodyBlock(for card: Card) -> some View {
|
|
CardBodyView(body: card.body)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func detailsFooter(for card: Card) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Created \(card.created.value.map(Self.formatted) ?? "—")")
|
|
Text("Modified \(card.modified.value.map(Self.formatted) ?? "—")")
|
|
}
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.top, 8)
|
|
}
|
|
|
|
private static func formatted(_ date: Date) -> String {
|
|
date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
}
|