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 above the lane rows 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.manual.rawValue private var session: BoardSession { index.session(forBoardAt: boardRoot) } /// The stored raw value as the enum, defaulting to `.manual` 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 { Binding( get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual }, set: { sortOrderRaw = $0.rawValue } ) } 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 } /// 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 } @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 { Picker("Sort", selection: sortOrder) { Text("Manual").tag(ItemSortOrder.manual) Text("Name").tag(ItemSortOrder.name) Text("Recent").tag(ItemSortOrder.recent) } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowSeparator(.hidden) // 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 } }