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
277 lines
12 KiB
Swift
277 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|