Files
lanework/KanbanMobile/Screens/BoardScreen.swift
T
rzen df986566e5 Rows learn to be carried — long-press reorder for lanes and cards, manual mode only
An onMove on both lists, live only while the sort picker says Manual:
the drag mints a midpoint rank between its landing neighbours through
BoardWriter.moveItem's same-parent reorder path, and when a gap is
exhausted the whole container renumbers to a fresh 1024 ladder in one
perform bracket. Neither path stamps modified, so a reorder never
disturbs the Recent sort. UI suite 4/4 in the simulator.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 13:28:24 -04:00

241 lines
11 KiB
Swift

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. A segmented control in the toolbar lets the list be sorted by
/// name or by most recent change, on top of the board's own manual arrangement, remembered across
/// launches. Long-pressing a row drags it to a new position while `.manual` is selected — under
/// `.name`/`.recent` the gesture is inert, since dragging a re-sorted list would reorder lanes the
/// user is not looking at in rank order.
struct BoardScreen: View {
let boardRoot: URL
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewLane = false
/// Persisted as its raw value, not the enum itself — same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`.
@AppStorage("laneListSortOrder") private var sortOrderRaw = ItemSortOrder.recent.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.recent` on anything the store didn't
/// write itself — an unset key or a stale raw value from a build that no longer has this case.
private var sortOrder: Binding<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent },
set: { sortOrderRaw = $0.rawValue }
)
}
var body: some View {
content
.navigationTitle(title)
.toolbar {
if case .ready = session.phase {
Button("New Lane", systemImage: "plus") {
isPresentingNewLane = true
}
}
}
.toolbar {
sortToolbarItem
}
.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
}
/// The row's display title, "Untitled Lane" fallback included — fed to `ItemSortOrder.sorted`
/// so `.name` sorts on exactly what the row shows.
private func displayTitle(of lane: Lane) -> String {
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
return title
}
/// Shown only once there is something to sort — an empty or not-yet-loaded lane list has no
/// rows for the segments to reorder.
@ToolbarContentBuilder
private var sortToolbarItem: some ToolbarContent {
if case .ready = session.phase, let snapshot = session.snapshot, !snapshot.lanes.isEmpty {
ToolbarItem(placement: .principal) {
Picker("Sort", selection: sortOrder) {
Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual)
Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name)
Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
}
}
}
@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 {
// The trusted order is `.manual`'s base — `BoardModel.lanes` is already in display
// order (the loader's `Ranks.sortedForDisplay`) — and `.name`/`.recent` layer a
// display-only re-sort on top, never touching that base or what's on disk.
ForEach(sortOrder.wrappedValue.sorted(snapshot.lanes, title: displayTitle(of:), modified: { $0.modified.value })) { lane in
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
LaneSummaryRow(lane: lane)
}
}
// `nil` under `.name`/`.recent` disables the drag outright — `onMove` accepts an
// optional closure, so the gate lives here rather than in a branch of the `ForEach`.
.onMove(perform: sortOrder.wrappedValue == .manual ? { moveRows(from: $0, to: $1) } : nil)
}
}
.refreshable { session.reload() }
}
/// Commits a lane drag's release, gated to `.manual` by the `onMove` call site above — reordering
/// a `.name`/`.recent` re-sort would rewrite ranks for rows the user isn't seeing in rank order.
///
/// `manualOrder` is `snapshot.lanes` with the drag already applied locally (`Array.move`), so the
/// dragged lane's post-drag index is both the row SwiftUI just drew and the position
/// `Ranks.insertionRank` mints a rank against — the same index convention `BoardStore.moveLane`
/// uses on the Mac. The happy path asks for the midpoint between the two neighbours the lane
/// lands between; on the rare board where that gap is already exhausted
/// (01-storage-format.md § Ordering), there is no `HealScheduler` to reach for — that engine
/// lives in `Kanban/LiveStore`, which this target does not compile — so the fallback renumbers
/// every lane to a fresh whole-multiple-of-1024 ladder in `manualOrder`'s own order, one
/// `BoardWriter.moveItem` reorder call per lane inside the same `session.perform` bracket. Both
/// paths are same-parent reorders, so neither stamps `modified` (the reorders-don't-stamp rule).
private func moveRows(from source: IndexSet, to destination: Int) {
guard let sourceIndex = source.first,
let snapshot = session.snapshot,
snapshot.lanes.indices.contains(sourceIndex)
else { return }
var manualOrder = snapshot.lanes
let moved = manualOrder[sourceIndex]
manualOrder.move(fromOffsets: IndexSet(integer: sourceIndex), toOffset: destination)
guard let landingIndex = manualOrder.firstIndex(where: { $0.id == moved.id }) else { return }
let siblingOrders = manualOrder.filter { $0.id != moved.id }.map(\.order)
// A `let` copy — the mutating `Array.move` above needs `manualOrder` as a `var`, but a
// `@Sendable` closure cannot capture a mutable var, and the fallback loop below needs the
// finished (post-move) arrangement, not the pre-move one.
let finalOrder = manualOrder
Task {
await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in
if let rank = Ranks.insertionRank(amongVisible: siblingOrders, at: landingIndex) {
return try BoardWriter.moveItem(
at: root.appendingPathComponent(moved.id.rawValue, isDirectory: true),
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: rank
)
}
for (lane, freshRank) in zip(finalOrder, Ranks.renumbered(count: finalOrder.count)) {
_ = try BoardWriter.moveItem(
at: root.appendingPathComponent(lane.id.rawValue, isDirectory: true),
toParent: root,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: freshRank
)
}
return MoveResult(id: moved.id, reminted: [])
}
}
}
}
/// One lane row: its icon when it has one, title, and its card count.
private struct LaneSummaryRow: View {
let lane: Lane
var body: some View {
HStack {
ItemIconView(icon: lane.icon.value, iconColor: lane.iconColor.value)
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
}
}