Files
lanework/KanbanMobile/Screens/CardDetailScreen.swift
T
rzen c4591ead2c Cards open to be read — the mobile detail screen turns read-only, editing moves behind a transactional Save
CardDetailScreen renders title, inline-Markdown body, and a quiet dates
footer; all writing moves to the new CardEditScreen, a full-screen cover
with segmented Details/Body panes. Drafts commit in one perform bracket
on Save only — Cancel guards dirty drafts with a discard confirmation,
and a failed write keeps the sheet and its drafts and raises an alert
instead of dismissing. CardAttributesSection becomes a pure
binding-driven editor with no write path of its own. The UI test walk
crosses the new split, with body-pane and discard coverage, and a
deterministic replaceAllText helper retires the flaky ⌘A select-all.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 14:47:50 -04:00

171 lines
7.3 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.
@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)
}
}