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,134 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The lane list for one board — `BoardRoute.lanes`'s destination.
|
||||
///
|
||||
/// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its
|
||||
/// snapshot are pulled from the environment's index store fresh on every body evaluation, so a
|
||||
/// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment
|
||||
/// the session's reload lands.
|
||||
struct BoardScreen: View {
|
||||
let boardRoot: URL
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@State private var isPresentingNewLane = false
|
||||
|
||||
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(title)
|
||||
.toolbar {
|
||||
if case .ready = session.phase {
|
||||
Button("New Lane", systemImage: "plus") {
|
||||
isPresentingNewLane = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { session.open() }
|
||||
.onDisappear {
|
||||
// Stops the materializing/downloading retry timer. In practice a no-op here: a
|
||||
// lane row (the only thing on this screen that pushes forward) only renders in
|
||||
// `.ready`, the one phase where `close()`'s `cancelRetry()` has nothing to cancel —
|
||||
// so pushing deeper into the board costs nothing, and popping back out to the
|
||||
// board list is where this actually stops the timer.
|
||||
session.close()
|
||||
}
|
||||
.titlePromptAlert("New Lane", isPresented: $isPresentingNewLane, placeholder: "Lane Title") { title in
|
||||
let newTitle: String? = title.isEmpty ? nil : title
|
||||
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) -> ItemID in
|
||||
try BoardWriter.createLane(inBoard: root, title: newTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
// The same fallback `BoardSummaryScanner` uses — the folder name minus `.kanban` — so the
|
||||
// title never blanks out while the session is still opening.
|
||||
session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch session.phase {
|
||||
case .idle, .loading:
|
||||
ProgressView("Opening board")
|
||||
|
||||
case let .materializing(progress):
|
||||
ProgressView(value: progress.fractionMaterialized) {
|
||||
Text("Downloading board")
|
||||
}
|
||||
.padding()
|
||||
|
||||
case let .failed(error):
|
||||
ContentUnavailableView {
|
||||
Label("Can't Open Board", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(error.description)
|
||||
} actions: {
|
||||
Button("Try Again") { session.reload() }
|
||||
}
|
||||
|
||||
case .ready:
|
||||
if let snapshot = session.snapshot {
|
||||
laneList(snapshot)
|
||||
} else {
|
||||
// Unreachable in practice — `.ready` is only ever set alongside a landed load,
|
||||
// which sets `snapshot` in the same step (`BoardSession.apply`). Kept so the
|
||||
// switch is exhaustive without a force-unwrap.
|
||||
ProgressView("Opening board")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func laneList(_ snapshot: BoardModel) -> some View {
|
||||
List {
|
||||
if session.lastError != nil {
|
||||
Section {
|
||||
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if snapshot.lanes.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Lanes Yet",
|
||||
systemImage: "rectangle.split.3x1",
|
||||
description: Text("Add a lane to start organizing cards.")
|
||||
)
|
||||
} else {
|
||||
// Already in display order (`BoardModel.lanes`'s own contract) — the loader's
|
||||
// `Ranks.sortedForDisplay` is trusted rather than re-sorted here.
|
||||
ForEach(snapshot.lanes) { lane in
|
||||
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
|
||||
LaneSummaryRow(lane: lane)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { session.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
/// One lane row: title, and its card count.
|
||||
private struct LaneSummaryRow: View {
|
||||
let lane: Lane
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle)
|
||||
Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
|
||||
return title
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user