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. @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) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background( (tintColor(for: card)?.opacity(0.15) ?? Color.clear), in: RoundedRectangle(cornerRadius: 12) ) } @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 `MarkdownBlocks` for why this is a per-paragraph /// `AttributedString(markdown:)` pass rather than a full block parser. @ViewBuilder private func bodyBlock(for card: Card) -> some View { let paragraphs = MarkdownBlocks.paragraphs(in: card.body) if paragraphs.isEmpty { Text("No description") .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 12) { ForEach(Array(paragraphs.enumerated()), id: \.offset) { _, paragraph in Text(MarkdownBlocks.rendered(paragraph)) } } } } @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) } } /// Dependency-free Markdown rendering for a card body: no `swift-markdown` block parser here, just /// `Foundation`'s own `AttributedString(markdown:options:)` run once per paragraph. /// /// **Why per-paragraph.** `AttributedString`'s built-in parser has no "render this as a sequence of /// blocks" mode short of `.full`, which produces a block tree this view would still have to walk — /// no simpler than doing the walk ourselves, and it collapses the very newlines a card body relies /// on to separate paragraphs. Splitting on blank lines first and parsing each paragraph with /// `.inlineOnlyPreservingWhitespace` gets both halves of what a card body needs cheaply: inline /// emphasis/links/code render as styled runs, and the newlines *inside* a paragraph (soft line /// breaks) survive as literal whitespace instead of being folded into a single space. enum MarkdownBlocks { /// Splits `body` on runs of one or more blank lines — Markdown's own paragraph boundary — and /// trims each block, so a body with trailing blank lines or extra spacing between paragraphs /// doesn't render phantom empty blocks. static func paragraphs(in body: String) -> [String] { body .components(separatedBy: "\n\n") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } /// One paragraph, inline-parsed. Malformed Markdown (an unterminated code span, say) falls back /// to the raw paragraph text rather than dropping it — the same "never lose the author's text" /// posture `CardPalette.color(named:in:)` takes for an unrecognized colour name. static func rendered(_ paragraph: String) -> AttributedString { let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) return (try? AttributedString(markdown: paragraph, options: options)) ?? AttributedString(paragraph) } }