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:
@@ -1,12 +1,10 @@
|
|||||||
**August 2026**
|
**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.
|
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.
|
The board list now opens sorted by most recent change.
|
||||||
|
|
||||||
Sort lanes and cards by your own arrangement, name, or most recent change.
|
|
||||||
|
|
||||||
Boards, lanes, and cards now show their icon and color in the lists.
|
Boards, lanes, and cards now show their icon and color in the lists.
|
||||||
|
|
||||||
|
|||||||
@@ -5,32 +5,16 @@ import SwiftUI
|
|||||||
/// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its
|
/// 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
|
/// 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
|
/// 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
|
/// the session's reload lands. Long-pressing a row drags it to a new position, reordering the
|
||||||
/// name or by most recent change, on top of the board's own manual arrangement, remembered across
|
/// board's own manual arrangement.
|
||||||
/// 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.
|
|
||||||
struct BoardScreen: View {
|
struct BoardScreen: View {
|
||||||
let boardRoot: URL
|
let boardRoot: URL
|
||||||
|
|
||||||
@Environment(BoardIndexStore.self) private var index
|
@Environment(BoardIndexStore.self) private var index
|
||||||
@State private var isPresentingNewLane = false
|
@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) }
|
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 {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.navigationTitle(title)
|
.navigationTitle(title)
|
||||||
@@ -41,9 +25,6 @@ struct BoardScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
|
||||||
sortToolbarItem
|
|
||||||
}
|
|
||||||
.task { session.open() }
|
.task { session.open() }
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Stops the materializing/downloading retry timer. In practice a no-op here: a
|
// 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
|
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
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
switch session.phase {
|
switch session.phase {
|
||||||
@@ -145,24 +102,20 @@ struct BoardScreen: View {
|
|||||||
description: Text("Add a lane to start organizing cards.")
|
description: Text("Add a lane to start organizing cards.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// The trusted order is `.manual`'s base — `BoardModel.lanes` is already in display
|
// `snapshot.lanes` is already in display order (the loader's `Ranks.sortedForDisplay`),
|
||||||
// order (the loader's `Ranks.sortedForDisplay`) — and `.name`/`.recent` layer a
|
// so it's trusted here rather than re-sorted.
|
||||||
// display-only re-sort on top, never touching that base or what's on disk.
|
ForEach(snapshot.lanes) { lane in
|
||||||
ForEach(sortOrder.wrappedValue.sorted(snapshot.lanes, title: displayTitle(of:), modified: { $0.modified.value })) { lane in
|
|
||||||
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
|
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
|
||||||
LaneSummaryRow(lane: lane)
|
LaneSummaryRow(lane: lane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// `nil` under `.name`/`.recent` disables the drag outright — `onMove` accepts an
|
.onMove(perform: { moveRows(from: $0, to: $1) })
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable { session.reload() }
|
.refreshable { session.reload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits a lane drag's release, gated to `.manual` by the `onMove` call site above — reordering
|
/// Commits a lane drag's release.
|
||||||
/// a `.name`/`.recent` re-sort would rewrite ranks for rows the user isn't seeing in rank order.
|
|
||||||
///
|
///
|
||||||
/// `manualOrder` is `snapshot.lanes` with the drag already applied locally (`Array.move`), so the
|
/// `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
|
/// dragged lane's post-drag index is both the row SwiftUI just drew and the position
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import SwiftUI
|
|||||||
/// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from
|
/// 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
|
/// `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
|
/// 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
|
/// lands, and a lane deleted on another device is noticed rather than shown stale. Long-pressing
|
||||||
/// control in the toolbar lets the list be sorted by name or by most recent change, on top
|
/// a card drags it to a new position, reordering the lane's own manual arrangement.
|
||||||
/// 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.
|
|
||||||
struct LaneScreen: View {
|
struct LaneScreen: View {
|
||||||
let boardRoot: URL
|
let boardRoot: URL
|
||||||
let laneID: ItemID
|
let laneID: ItemID
|
||||||
@@ -20,21 +17,8 @@ struct LaneScreen: View {
|
|||||||
@State private var isPresentingNewCard = false
|
@State private var isPresentingNewCard = false
|
||||||
@State private var cardPendingMove: ItemID?
|
@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) }
|
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? {
|
private var lane: Lane? {
|
||||||
session.snapshot?.lanes.first { $0.id == laneID }
|
session.snapshot?.lanes.first { $0.id == laneID }
|
||||||
}
|
}
|
||||||
@@ -49,9 +33,6 @@ struct LaneScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
|
||||||
sortToolbarItem
|
|
||||||
}
|
|
||||||
.task { session.open() }
|
.task { session.open() }
|
||||||
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
|
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
|
||||||
createCard(titled: title.isEmpty ? nil : title)
|
createCard(titled: title.isEmpty ? nil : title)
|
||||||
@@ -86,30 +67,6 @@ struct LaneScreen: View {
|
|||||||
return title
|
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
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
if let lane {
|
if let lane {
|
||||||
@@ -128,11 +85,9 @@ struct LaneScreen: View {
|
|||||||
description: Text("Add a card to this lane.")
|
description: Text("Add a card to this lane.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// The trusted order is `.manual`'s base — `Lane.cards` is already in display
|
// `lane.cards` is already in display order, exactly as the lane list trusts
|
||||||
// order, exactly as the lane list trusts `BoardModel.lanes` — and `.name`/
|
// `BoardModel.lanes` — so it's trusted here rather than re-sorted.
|
||||||
// `.recent` layer a display-only re-sort on top, never touching that base or
|
ForEach(lane.cards) { card in
|
||||||
// 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)) {
|
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
|
||||||
CardSummaryRow(card: card)
|
CardSummaryRow(card: card)
|
||||||
}
|
}
|
||||||
@@ -154,9 +109,7 @@ struct LaneScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// `nil` under `.name`/`.recent` disables the drag outright — `onMove` accepts an
|
.onMove(perform: { moveRows(from: $0, to: $1) })
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable { session.reload() }
|
.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
|
/// Commits a card drag's release.
|
||||||
/// a `.name`/`.recent` re-sort would rewrite ranks for rows the user isn't seeing in rank order.
|
|
||||||
///
|
///
|
||||||
/// `manualOrder` is `lane.cards` with the drag already applied locally (`Array.move`), so the
|
/// `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
|
/// dragged card's post-drag index is both the row SwiftUI just drew and the position
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user