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:
@@ -0,0 +1,276 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user