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) } }