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
158 lines
5.9 KiB
Swift
158 lines
5.9 KiB
Swift
import SwiftUI
|
|
|
|
/// The card editor's Attributes section: icon, icon colour, and background colour — the three
|
|
/// *typed* style fields a card carries (`FrontmatterFields.icon`/`.iconColor`/`.background`).
|
|
///
|
|
/// **Drafted, not written.** Every pick here mutates a `Binding<String?>` the caller owns —
|
|
/// `CardEditScreen`'s own `@State` drafts — rather than reaching for `BoardWriter` itself: the
|
|
/// transactional edit model commits title, body, and these three keys together in one `Save`, so
|
|
/// this section has no session, no card, and no write code of its own. It is a pure "edit these
|
|
/// three bindings" view; the icon picker sheet is unchanged mechanically, it just feeds a binding
|
|
/// instead of firing a write.
|
|
///
|
|
/// **There is no labels row.** `labels` is not a field either `BoardModel` or `FrontmatterDocument`
|
|
/// exposes as typed: `BoardModel.document`'s own doc comment names it as one of the reserved keys
|
|
/// that "ride along uninterpreted via `document.unknownFields`", alongside `assignees`, `due`,
|
|
/// `remote`. Rendering or editing it here would mean this screen parsing and rewriting a key the
|
|
/// model layer deliberately treats as opaque — exactly the unknown-field promise `updateIndex`'s
|
|
/// surgical edits exist to keep (agent-written or hand-written overlays round-trip untouched). If
|
|
/// a typed `labels` field is ever added to the storage schema, its editor belongs in this section.
|
|
struct CardAttributesSection: View {
|
|
@Binding var icon: String?
|
|
@Binding var iconColor: String?
|
|
@Binding var background: String?
|
|
|
|
@State private var isPresentingIconPicker = false
|
|
|
|
var body: some View {
|
|
Section("Attributes") {
|
|
Button {
|
|
isPresentingIconPicker = true
|
|
} label: {
|
|
LabeledContent("Icon") {
|
|
if let icon, !icon.isEmpty {
|
|
Image(systemName: icon)
|
|
} else {
|
|
Text("None").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.tint(.primary)
|
|
|
|
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: iconColor) { name in
|
|
iconColor = name
|
|
}
|
|
|
|
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: background) { name in
|
|
background = name
|
|
}
|
|
}
|
|
.sheet(isPresented: $isPresentingIconPicker) {
|
|
IconPickerSheet(current: icon) { name in
|
|
icon = name
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func swatchRow(
|
|
title: String,
|
|
swatches: [CardPalette.Swatch],
|
|
current: String?,
|
|
onSelect: @escaping (String?) -> Void
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
SwatchButton(isSelected: current == nil, color: nil) { onSelect(nil) }
|
|
ForEach(swatches) { swatch in
|
|
SwatchButton(
|
|
isSelected: current == swatch.name,
|
|
color: CardPalette.color(named: swatch.name, in: swatches)
|
|
) {
|
|
onSelect(swatch.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
/// One colour well: a filled circle, a slashed placeholder for "None", and a selection ring.
|
|
private struct SwatchButton: View {
|
|
let isSelected: Bool
|
|
let color: Color?
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Circle()
|
|
.fill(color ?? Color(.systemGray5))
|
|
.frame(width: 28, height: 28)
|
|
.overlay {
|
|
if color == nil {
|
|
Image(systemName: "slash.circle")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.overlay {
|
|
Circle()
|
|
.strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
|
|
.padding(-3)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
/// The icon picker's grid — `CardPalette.icons` plus a "None" well that removes the key.
|
|
private struct IconPickerSheet: View {
|
|
let current: String?
|
|
let onSelect: (String?) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private let columns = Array(repeating: GridItem(.flexible()), count: 6)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 20) {
|
|
Button {
|
|
onSelect(nil)
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "slash.circle")
|
|
.font(.title2)
|
|
.foregroundStyle(current == nil ? Color.accentColor : .secondary)
|
|
}
|
|
ForEach(CardPalette.icons, id: \.self) { name in
|
|
Button {
|
|
onSelect(name)
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: name)
|
|
.font(.title2)
|
|
.foregroundStyle(current == name ? Color.accentColor : .primary)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.navigationTitle("Icon")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|