import SwiftUI /// The card editor — title, body, and attributes — `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. /// /// ### 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. struct CardDetailScreen: View { let boardRoot: URL let laneID: ItemID let cardID: ItemID @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 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 { ToolbarItem(placement: .confirmationAction) { Button("Done") { isBodyFocused = false commitAll() } } } .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() } } } @ViewBuilder private var content: some View { if let card { Form { if session.lastError != nil { Section { 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) ?? "—") } } } else if case .ready = session.phase { ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted.")) .task { dismiss() } } else { ProgressView("Opening card") } } private func commitAll() { guard let card else { return } commitTitle(against: card) commitBody(against: card) } 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) } } } } } 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) } } } private static func formatted(_ date: Date) -> String { date.formatted(date: .abbreviated, time: .shortened) } }