The boards stack becomes the root screen: Settings moves from its own tab to a sheet behind a leading gearshape button, the sort pickers trade their text segments for symbols (hand.draw, textformat.abc, clock) with the words kept for accessibility, and every list now opens sorted by most recent change. UI tests re-aimed at the gear button and Done-button scoping fixed for the sheet-over-sheet nav bars — 4/4 green in the simulator. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
181 lines
7.5 KiB
Swift
181 lines
7.5 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.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.refreshable { session.reload() }
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|