The lane and card lists give up their pickers — rank order is the only order below the boards

The sort control stays on the board list alone; lanes and cards return
to the arrangement the ranks state, long-press reorder now ungated
because the rows always show the order a drag would rewrite.
ItemSortOrder leaves with the pickers that needed it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 13:41:09 -04:00
parent 2d919b8131
commit 28abaac2d9
4 changed files with 16 additions and 169 deletions
+2 -4
View File
@@ -1,12 +1,10 @@
**August 2026**
Press and hold a lane or card to drag it into a new order when sorting is set to manual.
Press and hold a lane or card to drag it into a new order.
Settings now lives behind the gear button on the board list instead of a separate tab, so boards fill the whole screen.
Lists now open sorted by most recent change.
Sort lanes and cards by your own arrangement, name, or most recent change.
The board list now opens sorted by most recent change.
Boards, lanes, and cards now show their icon and color in the lists.
+7 -54
View File
@@ -5,32 +5,16 @@ 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. 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.
/// the session's reload lands. Long-pressing a row drags it to a new position, reordering the
/// board's own manual arrangement.
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)
@@ -41,9 +25,6 @@ struct BoardScreen: View {
}
}
}
.toolbar {
sortToolbarItem
}
.task { session.open() }
.onDisappear {
// Stops the materializing/downloading retry timer. In practice a no-op here: a
@@ -71,30 +52,6 @@ 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
}
/// 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 {
@@ -145,24 +102,20 @@ struct BoardScreen: View {
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
// `snapshot.lanes` is already in display order (the loader's `Ranks.sortedForDisplay`),
// so it's trusted here rather than re-sorted.
ForEach(snapshot.lanes) { 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)
.onMove(perform: { moveRows(from: $0, to: $1) })
}
}
.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.
/// Commits a lane drag's release.
///
/// `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
+7 -55
View File
@@ -5,11 +5,8 @@ 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. A segmented
/// control in the toolbar lets the list be sorted by name or by most recent change, on top
/// of the lane's own manual arrangement, remembered across launches. Long-pressing a card drags it
/// to a new position while `.manual` is selected, for the same reason the lane list's drag is gated
/// `.name`/`.recent` are not the rank order a reorder would rewrite.
/// lands, and a lane deleted on another device is noticed rather than shown stale. Long-pressing
/// a card drags it to a new position, reordering the lane's own manual arrangement.
struct LaneScreen: View {
let boardRoot: URL
let laneID: ItemID
@@ -20,21 +17,8 @@ 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.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 }
)
}
private var lane: Lane? {
session.snapshot?.lanes.first { $0.id == laneID }
}
@@ -49,9 +33,6 @@ struct LaneScreen: View {
}
}
}
.toolbar {
sortToolbarItem
}
.task { session.open() }
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
createCard(titled: title.isEmpty ? nil : title)
@@ -86,30 +67,6 @@ 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
}
/// Shown only once there is something to sort an empty or not-yet-loaded card list has no
/// rows for the segments to reorder.
@ToolbarContentBuilder
private var sortToolbarItem: some ToolbarContent {
if let lane, !lane.cards.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 {
if let lane {
@@ -128,11 +85,9 @@ struct LaneScreen: View {
description: Text("Add a card to this lane.")
)
} else {
// 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
// `lane.cards` is already in display order, exactly as the lane list trusts
// `BoardModel.lanes` so it's trusted here rather than re-sorted.
ForEach(lane.cards) { card in
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
CardSummaryRow(card: card)
}
@@ -154,9 +109,7 @@ struct LaneScreen: View {
}
}
}
// `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)
.onMove(perform: { moveRows(from: $0, to: $1) })
}
}
.refreshable { session.reload() }
@@ -216,8 +169,7 @@ struct LaneScreen: View {
}
}
/// Commits a card 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.
/// Commits a card drag's release.
///
/// `manualOrder` is `lane.cards` with the drag already applied locally (`Array.move`), so the
/// dragged card's post-drag index is both the row SwiftUI just drew and the position
-56
View File
@@ -1,56 +0,0 @@
import Foundation
/// How the lane and card lists order their rows. Raw values are persisted (`AppStorage`), so
/// they are API. `.manual` is the board's own arrangement the rank order the loader already
/// delivered and sorting here is display-only: it never rewrites ranks on disk.
enum ItemSortOrder: String, CaseIterable, Sendable {
case manual
case name
case recent
}
extension ItemSortOrder {
/// Orders `items` for display without touching the manual arrangement they arrived in.
///
/// **`.manual` is a no-op** `items` is already in the loader's rank order, so this returns it
/// unchanged.
///
/// **`.name` sorts by `title`**, case/diacritic-insensitive (`localizedStandardCompare`). Callers
/// pass the row's own display title the "Untitled Lane"/"Untitled Card" fallback already
/// applied so there is no separate nil case to sort here.
///
/// **`.recent` sorts by `modified` descending, with `nil` last.** Both sorts tie-break the same
/// way: Swift's `sort` is not a stable sort, so ties equal titles, equal or absent dates keep
/// the incoming order via each item's original offset, exactly as `BoardSortOrder.sorted` does.
nonisolated func sorted<Item>(
_ items: [Item],
title: (Item) -> String,
modified: (Item) -> Date?
) -> [Item] {
switch self {
case .manual:
return items
case .name:
return items.enumerated().sorted { lhs, rhs in
let comparison = title(lhs.element).localizedStandardCompare(title(rhs.element))
if comparison != .orderedSame { return comparison == .orderedAscending }
return lhs.offset < rhs.offset
}.map(\.element)
case .recent:
return items.enumerated().sorted { lhs, rhs in
switch (modified(lhs.element), modified(rhs.element)) {
case let (l?, r?) where l != r:
return l > r
case (nil, .some):
return false
case (.some, nil):
return true
default:
return lhs.offset < rhs.offset
}
}.map(\.element)
}
}
}