The phone joins the format — KanbanMobile MVP: shared storage verbatim over an iCloud container
A second product, not a second edition: dev.rzen.indie.KanbanMobile (iOS 26,
iPhone-only) compiles Kanban/Storage as source files, so a format change that
breaks the phone breaks this build the day it's made. Boards live in
iCloud.dev.rzen.indie.Kanban — named after the Mac bundle id so the Mac app can
adopt the container later without a migration; until then the folder is
"Lanework" in iCloud Drive and the Mac opens boards there through the open
panel.
EchoLedger grows #if os(macOS) gates around its three consumer surfaces
(verdicts/BoardDiff, harvest/HarvestedReceipt, comment retirement/CommentPath)
— the recording side BoardWriter stamps compiles on every platform, and the
gates are the seam a future phone verdict surface lands behind. AgentGuide
stays Mac-only.
The phone's watcher is NSMetadataQuery: BoardIndexStore (one query, package
UTI export makes a .kanban directory one item, equality-gated rescans,
download kicks per pass), BoardSession (materialization sweep before every
fail-fast walk, NSFileCoordinator brackets, perform{} = coordinated write then
awaited reload, ParseMemo threaded), CloudHome (off-main container resolution,
LANEWORK_LOCAL_ROOT DEBUG override for simulator work without an account).
Screens: Boards -> lanes -> cards -> card editor, value-routed by ItemID with
every screen re-reading the live snapshot; leading swipe moves a card via
confirmationDialog, trailing swipe sends it to .trash/; the editor commits
title through the Mac's canonical rename path and body through writeBody, with
drafts that survive reloads; attributes are the three typed style fields
(icon, iconColor, background) — labels is a reserved unknown-field key and
deliberately has no editor. Settings carries IndieBackup (backup root = the
container's Documents, restore rebuild = an index rescan, controller
constructed only once the home resolves).
Arbiter: KanbanMobile green for iOS Simulator, Kanban green for macOS, 3006
unit tests / 517 suites passed (PointerLatencyTests excluded — mid-rework
uncommitted in a parallel session).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The card detail screen's Attributes section: icon, icon colour, and background colour — the
|
||||
/// three *typed* style fields a card carries (`FrontmatterFields.icon`/`.iconColor`/`.background`).
|
||||
/// Every pick writes immediately through `BoardWriter.updateIndex` — these are one-tap choices
|
||||
/// from a fixed set, not free text, so there is no draft to debounce the way title/body have.
|
||||
///
|
||||
/// **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 {
|
||||
let card: Card
|
||||
let laneID: ItemID
|
||||
let cardID: ItemID
|
||||
let session: BoardSession
|
||||
|
||||
@State private var isPresentingIconPicker = false
|
||||
|
||||
var body: some View {
|
||||
Section("Attributes") {
|
||||
Button {
|
||||
isPresentingIconPicker = true
|
||||
} label: {
|
||||
LabeledContent("Icon") {
|
||||
if let icon = card.icon.value, !icon.isEmpty {
|
||||
Image(systemName: icon)
|
||||
} else {
|
||||
Text("None").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
|
||||
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: card.iconColor.value) { name in
|
||||
setStyle(FrontmatterKeys.iconColor, to: name)
|
||||
}
|
||||
|
||||
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: card.background.value) { name in
|
||||
setStyle(FrontmatterKeys.background, to: name)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isPresentingIconPicker) {
|
||||
IconPickerSheet(current: card.icon.value) { name in
|
||||
setStyle(FrontmatterKeys.icon, to: 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)
|
||||
}
|
||||
|
||||
/// Writes one style key immediately. `operation: .style` is the vocabulary's own case for
|
||||
/// "`updateIndex` on behalf of styling flows" (`WriteOperation.style`); `card.title.value` is
|
||||
/// read before the closure runs so a failure banner can still name the card by the title on
|
||||
/// screen.
|
||||
private func setStyle(_ key: String, to value: String?) {
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let cardTitle = card.title.value
|
||||
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.
|
||||
await session.perform { (root: URL) throws(BoardWriteError) -> Void in
|
||||
let folder = root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in
|
||||
document.setStyleValue(value, for: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user