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,205 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The card list for one lane — `BoardRoute.cards`'s destination.
|
||||
///
|
||||
/// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from
|
||||
/// `session.snapshot` on every body evaluation, so a write this screen makes — or one relayed
|
||||
/// from elsewhere through the metadata query — reaches the list the moment the session's reload
|
||||
/// lands, and a lane deleted on another device is noticed rather than shown stale.
|
||||
struct LaneScreen: View {
|
||||
let boardRoot: URL
|
||||
let laneID: ItemID
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isPresentingNewCard = false
|
||||
@State private var cardPendingMove: ItemID?
|
||||
|
||||
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
|
||||
|
||||
private var lane: Lane? {
|
||||
session.snapshot?.lanes.first { $0.id == laneID }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(navigationTitle)
|
||||
.toolbar {
|
||||
if lane != nil {
|
||||
Button("New Card", systemImage: "plus") {
|
||||
isPresentingNewCard = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { session.open() }
|
||||
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
|
||||
createCard(titled: title.isEmpty ? nil : title)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Move Card",
|
||||
isPresented: Binding(get: { cardPendingMove != nil }, set: { if !$0 { cardPendingMove = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(otherLanes) { destination in
|
||||
Button(displayTitle(of: destination)) {
|
||||
if let cardID = cardPendingMove {
|
||||
move(cardID, to: destination.id)
|
||||
}
|
||||
cardPendingMove = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { cardPendingMove = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationTitle: String {
|
||||
lane.map(displayTitle(of:)) ?? "Lane"
|
||||
}
|
||||
|
||||
private var otherLanes: [Lane] {
|
||||
(session.snapshot?.lanes ?? []).filter { $0.id != laneID }
|
||||
}
|
||||
|
||||
private func displayTitle(of lane: Lane) -> String {
|
||||
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
|
||||
return title
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let lane {
|
||||
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 lane.cards.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Cards Yet",
|
||||
systemImage: "rectangle.on.rectangle",
|
||||
description: Text("Add a card to this lane.")
|
||||
)
|
||||
} else {
|
||||
// Already in display order (`Lane.cards`'s own contract) — trusted rather than
|
||||
// re-sorted here, exactly as the lane list trusts `BoardModel.lanes`.
|
||||
ForEach(lane.cards) { card in
|
||||
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
|
||||
CardSummaryRow(card: card)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
trash(card.id)
|
||||
} label: {
|
||||
Label("Trash", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if !otherLanes.isEmpty {
|
||||
Button {
|
||||
cardPendingMove = card.id
|
||||
} label: {
|
||||
Label("Move", systemImage: "arrow.right.arrow.left")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { session.reload() }
|
||||
} else if case .ready = session.phase {
|
||||
// The lane is gone from a `.ready` snapshot — deleted elsewhere while this screen was
|
||||
// open. Nothing left to list; back out rather than leave a dead screen on top of the
|
||||
// stack.
|
||||
ContentUnavailableView("Lane Removed", systemImage: "trash", description: Text("This lane was deleted."))
|
||||
.task { dismiss() }
|
||||
} else {
|
||||
ProgressView("Opening lane")
|
||||
}
|
||||
}
|
||||
|
||||
private func createCard(titled title: String?) {
|
||||
let laneID = self.laneID
|
||||
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
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
return try BoardWriter.createCard(inLane: laneFolder, title: title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func trash(_ cardID: ItemID) {
|
||||
let laneID = self.laneID
|
||||
Task {
|
||||
await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in
|
||||
let cardFolder = root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
return try BoardWriter.deleteCardToTrash(at: cardFolder, inBoard: root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func move(_ cardID: ItemID, to destinationLaneID: ItemID) {
|
||||
let laneID = self.laneID
|
||||
Task {
|
||||
await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in
|
||||
let sourceFolder = root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
||||
let destinationParent = root.appendingPathComponent(destinationLaneID.rawValue, isDirectory: true)
|
||||
// `order: nil` — append at the end of the destination lane's visible cards, the
|
||||
// same placement a hand-created card gets.
|
||||
return try BoardWriter.moveItem(
|
||||
at: sourceFolder,
|
||||
toParent: destinationParent,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One card row: title, an attachment-count hint, and the card's own icon when it has one.
|
||||
///
|
||||
/// **No label chips.** `labels` is not a field `BoardModel`/`FrontmatterFields` expose — see
|
||||
/// `CardAttributesSection`'s doc comment for why — so `attachments` (a field the model already
|
||||
/// carries cheaply) stands in as the row's "cheap material" instead.
|
||||
private struct CardSummaryRow: View {
|
||||
let card: Card
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle)
|
||||
if !card.attachments.isEmpty {
|
||||
Label("\(card.attachments.count)", systemImage: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let icon = card.icon.value, !icon.isEmpty {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(iconTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
|
||||
return title
|
||||
}
|
||||
|
||||
private var iconTint: Color {
|
||||
card.iconColor.value.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user