The phone's rows learn their faces and two more orders — icons everywhere, lanes and cards sortable

Boards, lanes, and cards all show their frontmatter icon leading the
row, tinted through the palette with a secondary fallback; the board
scanner now reads icon and iconColor from the same one-file parse as
the title. Lane and card lists gain the segmented Manual/Name/Recent
control — display-only sorting layered over the rank order, never
rewriting what's on disk.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 12:34:10 -04:00
parent 445d035a83
commit e8791b239d
7 changed files with 209 additions and 44 deletions
+45 -10
View File
@@ -5,15 +5,30 @@ import SwiftUI
/// 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.
/// 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<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual },
set: { sortOrderRaw = $0.rawValue }
)
}
var body: some View {
content
.navigationTitle(title)
@@ -51,6 +66,13 @@ struct BoardScreen: View {
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 {
@@ -101,9 +123,19 @@ struct BoardScreen: View {
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
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)
}
@@ -114,16 +146,19 @@ struct BoardScreen: View {
}
}
/// One lane row: title, and its card count.
/// 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 {
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.secondary)
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)
}
}
}