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
114 lines
4.1 KiB
Swift
114 lines
4.1 KiB
Swift
import SwiftUI
|
|
|
|
/// The root of the boards navigation stack: board list → lanes → cards → card detail
|
|
/// (`BoardRoute`'s destinations).
|
|
///
|
|
/// Renders all three of `BoardIndexStore.Phase` — the property the placeholder this replaces
|
|
/// existed to prove, and the split this screen keeps.
|
|
struct BoardsTabView: View {
|
|
@Environment(BoardIndexStore.self) private var index
|
|
|
|
@State private var isPresentingNewBoard = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
.navigationTitle("Boards")
|
|
.toolbar {
|
|
if case .ready = index.phase {
|
|
Button("New Board", systemImage: "plus") {
|
|
isPresentingNewBoard = true
|
|
}
|
|
}
|
|
}
|
|
.navigationDestination(for: BoardRoute.self) { route in
|
|
switch route {
|
|
case let .lanes(boardRoot):
|
|
BoardScreen(boardRoot: boardRoot)
|
|
case let .cards(boardRoot, laneID):
|
|
LaneScreen(boardRoot: boardRoot, laneID: laneID)
|
|
case let .card(boardRoot, laneID, cardID):
|
|
CardDetailScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
|
|
}
|
|
}
|
|
}
|
|
.task { index.start() }
|
|
.titlePromptAlert("New Board", isPresented: $isPresentingNewBoard, placeholder: "Board Title") { title in
|
|
Task { await index.createBoard(titled: title) }
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch index.phase {
|
|
case .idle, .loading:
|
|
ProgressView("Looking for boards")
|
|
|
|
case let .unavailable(reason):
|
|
// The hard-iCloud-requirement wall. `reason` distinguishes "sign in" from "the container
|
|
// did not resolve", which are different asks of the user.
|
|
ContentUnavailableView {
|
|
Label("iCloud Required", systemImage: "icloud.slash")
|
|
} description: {
|
|
Text(reason == .noAccount
|
|
? "Sign into iCloud in Settings to use Lanework."
|
|
: "Lanework can't reach its iCloud Drive folder right now.")
|
|
} actions: {
|
|
Button("Try Again") { index.start() }
|
|
}
|
|
|
|
case .ready where index.boards.isEmpty:
|
|
ContentUnavailableView(
|
|
"No Boards Yet",
|
|
systemImage: "rectangle.stack",
|
|
description: Text("Boards in your iCloud Drive will appear here.")
|
|
)
|
|
|
|
case .ready:
|
|
List(index.boards) { board in
|
|
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
|
|
BoardSummaryRow(board: board)
|
|
}
|
|
}
|
|
.refreshable { index.refresh() }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One board row: title, lane/card counts, modified date, and — while the package is not fully
|
|
/// current — a download-state subtitle in place of the counts a shallow scan cannot yet answer.
|
|
private struct BoardSummaryRow: View {
|
|
let board: BoardSummary
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(board.title)
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var subtitle: String {
|
|
switch board.download {
|
|
case .notDownloaded:
|
|
"Waiting to download"
|
|
case let .downloading(fraction):
|
|
fraction.map { "Downloading \(Int($0 * 100))%" } ?? "Downloading"
|
|
case .current, .unknown:
|
|
countsAndModified
|
|
}
|
|
}
|
|
|
|
private var countsAndModified: String {
|
|
let counts: String
|
|
if let lanes = board.laneCount, let cards = board.cardCount {
|
|
counts = "\(lanes) lane\(lanes == 1 ? "" : "s") · \(cards) card\(cards == 1 ? "" : "s")"
|
|
} else {
|
|
counts = "—"
|
|
}
|
|
guard let modified = board.modified else { return counts }
|
|
return "\(counts) · \(modified.formatted(.relative(presentation: .named)))"
|
|
}
|
|
}
|