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
This commit is contained in:
@@ -1,22 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The card editor — title, body, and attributes — `BoardRoute.card`'s destination.
|
||||
/// 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 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 edited into thin air.
|
||||
/// 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.
|
||||
///
|
||||
/// ### Save timing
|
||||
///
|
||||
/// Title and body are edited into local `@State` drafts and committed through `BoardWriter` only
|
||||
/// when they differ from the snapshot's own value — never on every keystroke. Three triggers cover
|
||||
/// every way editing can end without dropping a change: the field's own submit (title, on Return),
|
||||
/// `onDisappear` (the user navigates back), and `scenePhase` leaving `.active` (the user backgrounds
|
||||
/// the app, or is interrupted, mid-edit in the body editor). A `Done` toolbar button folds in the
|
||||
/// same commit and drops focus, for a user who wants an explicit "I'm finished" without leaving the
|
||||
/// screen. All four funnel through `commitAll()`, so there is exactly one place that decides what
|
||||
/// "changed" means for each field.
|
||||
/// **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
|
||||
@@ -24,11 +19,8 @@ struct CardDetailScreen: View {
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@State private var titleDraft = ""
|
||||
@State private var bodyDraft = ""
|
||||
@FocusState private var isBodyFocused: Bool
|
||||
@State private var isPresentingEdit = false
|
||||
|
||||
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
||||
|
||||
@@ -41,58 +33,35 @@ struct CardDetailScreen: View {
|
||||
.navigationTitle("Card")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
isBodyFocused = false
|
||||
commitAll()
|
||||
if card != nil {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Edit") { isPresentingEdit = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { session.open() }
|
||||
// Seeds the drafts once per card. `.task(id:)` re-runs only when `card?.id` changes —
|
||||
// nil while the session is still opening, then the card's own id once it lands — so a
|
||||
// reload that lands *while this screen is open* (including the reload this screen's
|
||||
// own commit triggers) never clobbers text the user is mid-typing.
|
||||
.task(id: card?.id) {
|
||||
guard let card else { return }
|
||||
titleDraft = card.title.value ?? ""
|
||||
bodyDraft = card.body
|
||||
}
|
||||
.onDisappear { commitAll() }
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase != .active { commitAll() }
|
||||
.fullScreenCover(isPresented: $isPresentingEdit) {
|
||||
CardEditScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let card {
|
||||
Form {
|
||||
if session.lastError != nil {
|
||||
Section {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Title") {
|
||||
TextField("Untitled Card", text: $titleDraft)
|
||||
.onSubmit { commitTitle(against: card) }
|
||||
}
|
||||
|
||||
Section("Body") {
|
||||
TextEditor(text: $bodyDraft)
|
||||
.frame(minHeight: 200)
|
||||
.focused($isBodyFocused)
|
||||
}
|
||||
|
||||
CardAttributesSection(card: card, laneID: laneID, cardID: cardID, session: session)
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "—")
|
||||
LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "—")
|
||||
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."))
|
||||
@@ -102,56 +71,100 @@ struct CardDetailScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func commitAll() {
|
||||
guard let card else { return }
|
||||
commitTitle(against: card)
|
||||
commitBody(against: 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)
|
||||
)
|
||||
}
|
||||
|
||||
private func commitTitle(against card: Card) {
|
||||
let trimmed = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed != (card.title.value ?? "") else { return }
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let newValue: String? = trimmed.isEmpty ? nil : trimmed
|
||||
Task {
|
||||
// The closure's throws type must be spelled out — a trailing closure literal does not
|
||||
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
|
||||
await session.perform { (root: URL) throws(BoardWriteError) -> Void in
|
||||
let folder = root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
// The Mac's own rename path, verbatim (`BoardStore.setTitle`): plain `set`/`remove`
|
||||
// — `setStyleValue` is the style gesture's helper, not the title's — and
|
||||
// `.rename(title: nil)` so `updateIndex` enriches the operation from the document
|
||||
// it reads rather than trusting a snapshot that may have aged.
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
||||
if let newValue {
|
||||
document.set(FrontmatterKeys.title, to: .string(newValue))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.title)
|
||||
}
|
||||
@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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commitBody(against card: Card) {
|
||||
guard bodyDraft != card.body else { return }
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let newBody = bodyDraft
|
||||
Task {
|
||||
await session.perform { (root: URL) throws(BoardWriteError) -> Bool in
|
||||
let folder = root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
return try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
|
||||
}
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user