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
|
||||
}
|
||||
}
|
||||
@@ -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)))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The card detail screen's Attributes section: icon, icon colour, and background colour — the
|
||||
/// three *typed* style fields a card carries (`FrontmatterFields.icon`/`.iconColor`/`.background`).
|
||||
/// Every pick writes immediately through `BoardWriter.updateIndex` — these are one-tap choices
|
||||
/// from a fixed set, not free text, so there is no draft to debounce the way title/body have.
|
||||
///
|
||||
/// **There is no labels row.** `labels` is not a field either `BoardModel` or `FrontmatterDocument`
|
||||
/// exposes as typed: `BoardModel.document`'s own doc comment names it as one of the reserved keys
|
||||
/// that "ride along uninterpreted via `document.unknownFields`", alongside `assignees`, `due`,
|
||||
/// `remote`. Rendering or editing it here would mean this screen parsing and rewriting a key the
|
||||
/// model layer deliberately treats as opaque — exactly the unknown-field promise `updateIndex`'s
|
||||
/// surgical edits exist to keep (agent-written or hand-written overlays round-trip untouched). If
|
||||
/// a typed `labels` field is ever added to the storage schema, its editor belongs in this section.
|
||||
struct CardAttributesSection: View {
|
||||
let card: Card
|
||||
let laneID: ItemID
|
||||
let cardID: ItemID
|
||||
let session: BoardSession
|
||||
|
||||
@State private var isPresentingIconPicker = false
|
||||
|
||||
var body: some View {
|
||||
Section("Attributes") {
|
||||
Button {
|
||||
isPresentingIconPicker = true
|
||||
} label: {
|
||||
LabeledContent("Icon") {
|
||||
if let icon = card.icon.value, !icon.isEmpty {
|
||||
Image(systemName: icon)
|
||||
} else {
|
||||
Text("None").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
|
||||
swatchRow(title: "Icon Color", swatches: CardPalette.foregrounds, current: card.iconColor.value) { name in
|
||||
setStyle(FrontmatterKeys.iconColor, to: name)
|
||||
}
|
||||
|
||||
swatchRow(title: "Background", swatches: CardPalette.backgrounds, current: card.background.value) { name in
|
||||
setStyle(FrontmatterKeys.background, to: name)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isPresentingIconPicker) {
|
||||
IconPickerSheet(current: card.icon.value) { name in
|
||||
setStyle(FrontmatterKeys.icon, to: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func swatchRow(
|
||||
title: String,
|
||||
swatches: [CardPalette.Swatch],
|
||||
current: String?,
|
||||
onSelect: @escaping (String?) -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
SwatchButton(isSelected: current == nil, color: nil) { onSelect(nil) }
|
||||
ForEach(swatches) { swatch in
|
||||
SwatchButton(
|
||||
isSelected: current == swatch.name,
|
||||
color: CardPalette.color(named: swatch.name, in: swatches)
|
||||
) {
|
||||
onSelect(swatch.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
/// Writes one style key immediately. `operation: .style` is the vocabulary's own case for
|
||||
/// "`updateIndex` on behalf of styling flows" (`WriteOperation.style`); `card.title.value` is
|
||||
/// read before the closure runs so a failure banner can still name the card by the title on
|
||||
/// screen.
|
||||
private func setStyle(_ key: String, to value: String?) {
|
||||
let laneID = self.laneID
|
||||
let cardID = self.cardID
|
||||
let cardTitle = card.title.value
|
||||
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)
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: cardTitle)) { document in
|
||||
document.setStyleValue(value, for: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One colour well: a filled circle, a slashed placeholder for "None", and a selection ring.
|
||||
private struct SwatchButton: View {
|
||||
let isSelected: Bool
|
||||
let color: Color?
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Circle()
|
||||
.fill(color ?? Color(.systemGray5))
|
||||
.frame(width: 28, height: 28)
|
||||
.overlay {
|
||||
if color == nil {
|
||||
Image(systemName: "slash.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
|
||||
.padding(-3)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon picker's grid — `CardPalette.icons` plus a "None" well that removes the key.
|
||||
private struct IconPickerSheet: View {
|
||||
let current: String?
|
||||
let onSelect: (String?) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible()), count: 6)
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 20) {
|
||||
Button {
|
||||
onSelect(nil)
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "slash.circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(current == nil ? Color.accentColor : .secondary)
|
||||
}
|
||||
ForEach(CardPalette.icons, id: \.self) { name in
|
||||
Button {
|
||||
onSelect(name)
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: name)
|
||||
.font(.title2)
|
||||
.foregroundStyle(current == name ? Color.accentColor : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Icon")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
import IndieBackup
|
||||
|
||||
/// App version, and backup/restore (indie-backup skill).
|
||||
struct SettingsTabView: View {
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.backupController) private var backupController
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Version") {
|
||||
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—")
|
||||
}
|
||||
}
|
||||
|
||||
backupSection
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
// Idempotent (BoardIndexStore.start()), and harmless if BoardsTabView already called it —
|
||||
// this tab can be the first one the user opens, and `backupController` only ever appears
|
||||
// once `index.home` resolves.
|
||||
.task { index.start() }
|
||||
}
|
||||
|
||||
/// `backupController` is `nil` until `MobileApp` has a resolved `CloudHome` to build one from
|
||||
/// — which is also exactly the condition under which there is a `Documents/` folder to back
|
||||
/// up. Everything short of that gets the same quiet explanation rather than a section that
|
||||
/// looks broken or, worse, a crash on a nil root.
|
||||
@ViewBuilder
|
||||
private var backupSection: some View {
|
||||
if let backupController {
|
||||
BackupsSectionView(controller: backupController)
|
||||
} else {
|
||||
Section {
|
||||
switch index.phase {
|
||||
case .idle, .loading:
|
||||
LabeledContent("Backup") {
|
||||
ProgressView()
|
||||
}
|
||||
case .unavailable, .ready:
|
||||
// `.ready` can appear briefly here too: `backupController` lags one runloop
|
||||
// turn behind `index.home` resolving (MobileApp's `.task(id:)`).
|
||||
Label("Backup and restore need iCloud Drive.", systemImage: "icloud.slash")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} footer: {
|
||||
Text("Sign into iCloud in Settings, then come back here to back up your boards.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user