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,157 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The card editor — title, body, and attributes — `BoardRoute.card`'s destination.
|
||||
///
|
||||
/// Holds board-relative IDs only, never a `Card` value: every body evaluation re-reads the card
|
||||
/// from `session.snapshot`, so a write this screen makes (or one that lands from elsewhere while
|
||||
/// it's open) is reflected the moment the session's reload lands, and a card trashed on another
|
||||
/// device is noticed rather than edited into thin air.
|
||||
///
|
||||
/// ### Save timing
|
||||
///
|
||||
/// Title and body are edited into local `@State` drafts and committed through `BoardWriter` only
|
||||
/// when they differ from the snapshot's own value — never on every keystroke. Three triggers cover
|
||||
/// every way editing can end without dropping a change: the field's own submit (title, on Return),
|
||||
/// `onDisappear` (the user navigates back), and `scenePhase` leaving `.active` (the user backgrounds
|
||||
/// the app, or is interrupted, mid-edit in the body editor). A `Done` toolbar button folds in the
|
||||
/// same commit and drops focus, for a user who wants an explicit "I'm finished" without leaving the
|
||||
/// screen. All four funnel through `commitAll()`, so there is exactly one place that decides what
|
||||
/// "changed" means for each field.
|
||||
struct CardDetailScreen: View {
|
||||
let boardRoot: URL
|
||||
let laneID: ItemID
|
||||
let cardID: ItemID
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@State private var titleDraft = ""
|
||||
@State private var bodyDraft = ""
|
||||
@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 {
|
||||
content
|
||||
.navigationTitle("Card")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
isBodyFocused = false
|
||||
commitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 screen is open* (including the reload this screen's
|
||||
// own commit triggers) never clobbers text the user is mid-typing.
|
||||
.task(id: card?.id) {
|
||||
guard let card else { return }
|
||||
titleDraft = card.title.value ?? ""
|
||||
bodyDraft = card.body
|
||||
}
|
||||
.onDisappear { commitAll() }
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase != .active { commitAll() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let card {
|
||||
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)
|
||||
.onSubmit { commitTitle(against: card) }
|
||||
}
|
||||
|
||||
Section("Body") {
|
||||
TextEditor(text: $bodyDraft)
|
||||
.frame(minHeight: 200)
|
||||
.focused($isBodyFocused)
|
||||
}
|
||||
|
||||
CardAttributesSection(card: card, laneID: laneID, cardID: cardID, session: session)
|
||||
|
||||
Section("Details") {
|
||||
LabeledContent("Created", value: card.created.value.map(Self.formatted) ?? "—")
|
||||
LabeledContent("Modified", value: card.modified.value.map(Self.formatted) ?? "—")
|
||||
}
|
||||
}
|
||||
} else if case .ready = session.phase {
|
||||
ContentUnavailableView("Card Removed", systemImage: "trash", description: Text("This card was deleted."))
|
||||
.task { dismiss() }
|
||||
} else {
|
||||
ProgressView("Opening card")
|
||||
}
|
||||
}
|
||||
|
||||
private func commitAll() {
|
||||
guard let card else { return }
|
||||
commitTitle(against: card)
|
||||
commitBody(against: card)
|
||||
}
|
||||
|
||||
private func commitTitle(against card: Card) {
|
||||
let trimmed = titleDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed != (card.title.value ?? "") else { return }
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let newValue: String? = trimmed.isEmpty ? nil : trimmed
|
||||
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)
|
||||
// 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 newValue {
|
||||
document.set(FrontmatterKeys.title, to: .string(newValue))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commitBody(against card: Card) {
|
||||
guard bodyDraft != card.body else { return }
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let newBody = bodyDraft
|
||||
Task {
|
||||
await session.perform { (root: URL) throws(BoardWriteError) -> Bool in
|
||||
let folder = root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
return try BoardWriter.writeBody(inItemFolder: folder, body: newBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func formatted(_ date: Date) -> String {
|
||||
date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user