Files
lanework/KanbanMobile/Screens/CardAttributesSection.swift
T
rzen c4591ead2c 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
2026-08-08 14:47:50 -04:00

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() }
}
}
}
}
}