import SwiftUI /// The card editor — title, body, and attributes, all drafted and committed together — presented /// full-screen from `CardDetailScreen`'s Edit button. /// /// Holds board-relative IDs only, never a `Card` value, for the same reason every other screen in /// this stack does: the card is re-read from `session.snapshot` on every body evaluation, so a /// write landing from elsewhere while this sheet is open (a trash from another device, say) is /// noticed rather than edited into thin air. /// /// ### Transactional, not auto-saving /// /// Every earlier screen in this app commits a field the moment it stops being edited — `onSubmit`, /// `onDisappear`, a scene-phase change. This one commits nothing until `Save`, deliberately: a /// `fullScreenCover` has no interactive swipe-to-dismiss, so the only way out mid-edit is a button /// this screen controls, which makes "ask before discarding" both possible and the more honest /// default for a screen with five drafted fields open across two panes at once. There is no /// scenePhase or onDisappear commit anywhere below — backgrounding the app or losing focus leaves /// the drafts exactly where they were, waiting for `Save` or `Cancel`. /// /// `Cancel` on a clean sheet dismisses immediately; on a dirty one it raises a confirmation before /// discarding. `Save` writes every changed field in one `session.perform` bracket — title through /// `.rename`, the three style keys through one `.style` call, body through `writeBody` — writes /// nothing at all when nothing changed, and dismisses only on success: a failed write keeps the /// sheet and its drafts and raises an alert (see `save()`). struct CardEditScreen: View { let boardRoot: URL let laneID: ItemID let cardID: ItemID @Environment(BoardIndexStore.self) private var index @Environment(\.dismiss) private var dismiss private enum Pane: String, CaseIterable, Hashable { case details = "Details" case body = "Body" } @State private var pane: Pane = .details @State private var titleDraft = "" @State private var bodyDraft = "" @State private var iconDraft: String? @State private var iconColorDraft: String? @State private var backgroundDraft: String? @State private var isConfirmingDiscard = false @State private var saveError: BoardSessionError? @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 { NavigationStack { content .navigationTitle("Card") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { requestDismiss() } } if card != nil { ToolbarItem(placement: .principal) { Picker("Pane", selection: $pane) { ForEach(Pane.allCases, id: \.self) { pane in Text(pane.rawValue).tag(pane) } } .pickerStyle(.segmented) .frame(maxWidth: 220) } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } } } } .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 sheet is open* never clobbers a // draft the user is mid-typing. Unlike the old in-place editor, nothing this // screen does triggers such a reload before `Save` — but another device's write // can still land, and the same guard covers that case too. .task(id: card?.id) { guard let card else { return } titleDraft = card.title.value ?? "" bodyDraft = card.body iconDraft = card.icon.value iconColorDraft = card.iconColor.value backgroundDraft = card.background.value } .confirmationDialog( "Discard Changes?", isPresented: $isConfirmingDiscard, titleVisibility: .visible ) { Button("Discard", role: .destructive) { dismiss() } Button("Cancel", role: .cancel) {} } .alert( "Couldn't Save", isPresented: Binding(get: { saveError != nil }, set: { if !$0 { saveError = nil } }), presenting: saveError ) { _ in Button("OK") { saveError = nil } } message: { error in Text(error.description) } } } @ViewBuilder private var content: some View { if let card { switch pane { case .details: detailsForm(for: card) case .body: TextEditor(text: $bodyDraft) .focused($isBodyFocused) .padding(.horizontal, 12) .padding(.top, 8) } } else if case .ready = session.phase { // The card is gone from a `.ready` snapshot — deleted elsewhere while this sheet was // open. There is nothing left to edit; dismiss the cover rather than leave drafts for // a card that no longer exists. ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted.")) .task { dismiss() } } else { ProgressView("Opening card") } } @ViewBuilder private func detailsForm(for card: Card) -> some View { 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) } CardAttributesSection(icon: $iconDraft, iconColor: $iconColorDraft, background: $backgroundDraft) Section("Details") { LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "—") LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "—") } } } /// Any draft differing from the card's own current snapshot value — title compared trimmed, /// exactly as `Save` compares it, so this predicate and the write it gates never disagree. private var isDirty: Bool { guard let card else { return false } let trimmedTitle = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedTitle != (card.title.value ?? "") || bodyDraft != card.body || iconDraft != card.icon.value || iconColorDraft != card.iconColor.value || backgroundDraft != card.background.value } private func requestDismiss() { if isDirty { isConfirmingDiscard = true } else { dismiss() } } /// One `session.perform` bracket, three independent writes inside it gated on whatever /// actually changed — a clean `Save` (nothing dirty) makes no write at all and just dismisses. /// Awaits the perform before dismissing: `perform` already awaits its own reload, so by the /// time this returns `CardDetailScreen`'s snapshot underneath already reflects the write, and /// the read screen shows the new values the instant the cover animates away. /// /// Dismissal is gated on the perform's own `Result` — the one caller of `perform` in this app /// that can't ignore it. The auto-committing screens could fire and forget because their /// drafts outlived the write attempt; here dismissing *is* draft destruction, so a failed /// write keeps the sheet (and every draft) exactly where it was and raises an alert instead. /// `perform` reloads even on failure, so a partially-applied multi-call write (title landed, /// body didn't) re-seeds nothing — the drafts keep the user's full intent and a retry rewrites /// only what still differs. private func save() { guard let card else { dismiss() return } let trimmedTitle = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines) let titleChanged = trimmedTitle != (card.title.value ?? "") let newTitle: String? = trimmedTitle.isEmpty ? nil : trimmedTitle let iconChanged = iconDraft != card.icon.value let iconColorChanged = iconColorDraft != card.iconColor.value let backgroundChanged = backgroundDraft != card.background.value let styleChanged = iconChanged || iconColorChanged || backgroundChanged let bodyChanged = bodyDraft != card.body guard titleChanged || styleChanged || bodyChanged else { dismiss() return } let laneID = self.laneID let cardID = self.cardID let cardTitle = card.title.value let newIcon = iconDraft let newIconColor = iconColorDraft let newBackground = backgroundDraft let newBody = bodyDraft 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. let outcome = await session.perform { (root: URL) throws(BoardWriteError) -> Void in let folder = root .appendingPathComponent(laneID.rawValue, isDirectory: true) .appendingPathComponent(cardID.rawValue, isDirectory: true) if titleChanged { // 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 newTitle { document.set(FrontmatterKeys.title, to: .string(newTitle)) } else { document.remove(FrontmatterKeys.title) } } } if styleChanged { // One `.style` call for all three keys — `operation: .style` is the // vocabulary's own case for "`updateIndex` on behalf of styling flows" // (`WriteOperation.style`), and each changed key writes through // `setStyleValue` exactly as the old immediate-write attributes section did. try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in if iconChanged { document.setStyleValue(newIcon, for: FrontmatterKeys.icon) } if iconColorChanged { document.setStyleValue(newIconColor, for: FrontmatterKeys.iconColor) } if backgroundChanged { document.setStyleValue(newBackground, for: FrontmatterKeys.background) } } } if bodyChanged { _ = try BoardWriter.writeBody(inItemFolder: folder, body: newBody) } } switch outcome { case .success: dismiss() case let .failure(error): saveError = error } } } private static func formatted(_ date: Date) -> String { date.formatted(date: .abbreviated, time: .shortened) } }