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,113 @@
|
||||
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)))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user