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)
}
}
}
+14 -11
View File
@@ -150,9 +150,9 @@ struct BoardsTabView: View {
}
}
/// One board row: title, lane/card counts, modified date, a marker for a board that is not in iCloud,
/// and while the package is not fully current a download-state subtitle in place of the counts a
/// shallow scan cannot yet answer.
/// One board row: its icon when it has one, title, lane/card counts, modified date, a marker for a
/// board that is not in iCloud, and while the package is not fully current a download-state
/// subtitle in place of the counts a shallow scan cannot yet answer.
///
/// Only the local side is marked. iCloud is where a board is expected to be, so saying so on every
/// row would be noise; "Local" is the exception, and the exception is what a marker is for.
@@ -160,16 +160,19 @@ private struct BoardSummaryRow: View {
let board: BoardSummary
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(board.title)
HStack(spacing: 6) {
if board.location == .local {
Label("Local", systemImage: "iphone")
HStack {
ItemIconView(icon: board.icon, iconColor: board.iconColor)
VStack(alignment: .leading, spacing: 2) {
Text(board.title)
HStack(spacing: 6) {
if board.location == .local {
Label("Local", systemImage: "iphone")
}
Text(subtitle)
}
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
+39 -14
View File
@@ -5,7 +5,9 @@ import SwiftUI
/// 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.
/// lands, and a lane deleted on another device is noticed rather than shown stale. A segmented
/// control above the card rows lets the list be sorted by name or by most recent change, on top
/// of the lane's own manual arrangement, remembered across launches.
struct LaneScreen: View {
let boardRoot: URL
let laneID: ItemID
@@ -16,8 +18,21 @@ struct LaneScreen: View {
@State private var isPresentingNewCard = false
@State private var cardPendingMove: ItemID?
/// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`.
@AppStorage("cardListSortOrder") 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 }
)
}
private var lane: Lane? {
session.snapshot?.lanes.first { $0.id == laneID }
}
@@ -66,6 +81,13 @@ struct LaneScreen: View {
return title
}
/// The row's display title, "Untitled Card" fallback included fed to `ItemSortOrder.sorted`
/// so `.name` sorts on exactly what the row shows.
private func displayTitle(of card: Card) -> String {
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
return title
}
@ViewBuilder
private var content: some View {
if let lane {
@@ -84,9 +106,20 @@ struct LaneScreen: View {
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
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 `Lane.cards` is already in display
// order, exactly as the lane list trusts `BoardModel.lanes` and `.name`/
// `.recent` layer a display-only re-sort on top, never touching that base or
// what's on disk.
ForEach(sortOrder.wrappedValue.sorted(lane.cards, title: displayTitle(of:), modified: { $0.modified.value })) { card in
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
CardSummaryRow(card: card)
}
@@ -168,7 +201,7 @@ struct LaneScreen: View {
}
}
/// One card row: title, an attachment-count hint, and the card's own icon when it has one.
/// One card row: its icon when it has one, title, and an attachment-count hint.
///
/// **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
@@ -178,6 +211,7 @@ private struct CardSummaryRow: View {
var body: some View {
HStack {
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle)
if !card.attachments.isEmpty {
@@ -186,11 +220,6 @@ private struct CardSummaryRow: View {
.foregroundStyle(.secondary)
}
}
Spacer()
if let icon = card.icon.value, !icon.isEmpty {
Image(systemName: icon)
.foregroundStyle(iconTint)
}
}
}
@@ -198,8 +227,4 @@ private struct CardSummaryRow: View {
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
}
}