The sort control leaves the list — three pickers settle into the bottom bar

Same segments, same persistence; the picker now rides each screen's
toolbar as a bottom-bar item shown only when there are rows to reorder,
instead of masquerading as the first row of the list it sorts.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 12:42:44 -04:00
parent e8791b239d
commit 49b2f4db6e
4 changed files with 60 additions and 30 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ 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.
Sort your board list by name or by most recently changed with the new control above the list. Sort your board list by name or by most recently changed with the sort control in the toolbar.
Version 1.0: Lanework comes to iPhone — browse your boards from iCloud Drive, move cards between lanes, and edit them on the go. Version 1.0: Lanework comes to iPhone — browse your boards from iCloud Drive, move cards between lanes, and edit them on the go.
+20 -10
View File
@@ -5,7 +5,7 @@ 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 above the lane rows lets the list be sorted by /// 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 /// name or by most recent change, on top of the board's own manual arrangement, remembered across
/// launches. /// launches.
struct BoardScreen: View { struct BoardScreen: View {
@@ -39,6 +39,9 @@ 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
@@ -73,6 +76,22 @@ struct BoardScreen: View {
return title 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: .bottomBar) {
Picker("Sort", selection: sortOrder) {
Text("Manual").tag(ItemSortOrder.manual)
Text("Name").tag(ItemSortOrder.name)
Text("Recent").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
}
}
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
switch session.phase { switch session.phase {
@@ -123,15 +142,6 @@ struct BoardScreen: View {
description: Text("Add a lane to start organizing cards.") description: Text("Add a lane to start organizing cards.")
) )
} else { } 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 // The trusted order is `.manual`'s base `BoardModel.lanes` is already in display
// order (the loader's `Ranks.sortedForDisplay`) and `.name`/`.recent` layer a // 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. // display-only re-sort on top, never touching that base or what's on disk.
+19 -9
View File
@@ -6,7 +6,7 @@ import SwiftUI
/// Renders both of `BoardIndexStore.Phase`'s live states and, above the list, the iCloud notice /// Renders both of `BoardIndexStore.Phase`'s live states and, above the list, the iCloud notice
/// which is a row, not a wall (softened 2026-08-08). A phone with no account still has a device home /// which is a row, not a wall (softened 2026-08-08). A phone with no account still has a device home
/// and therefore still has boards, so the missing half of the app is reported next to the half that /// and therefore still has boards, so the missing half of the app is reported next to the half that
/// works rather than in place of it. A segmented control above the rows lets the list be sorted by /// works rather than in place of it. A segmented control in the toolbar lets the list be sorted by
/// name or by most recent change, remembered across launches. /// name or by most recent change, remembered across launches.
struct BoardsTabView: View { struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index @Environment(BoardIndexStore.self) private var index
@@ -32,6 +32,9 @@ struct BoardsTabView: View {
} }
} }
} }
.toolbar {
sortToolbarItem
}
.navigationDestination(for: BoardRoute.self) { route in .navigationDestination(for: BoardRoute.self) { route in
switch route { switch route {
case let .lanes(boardRoot): case let .lanes(boardRoot):
@@ -73,6 +76,21 @@ struct BoardsTabView: View {
sortOrder.wrappedValue.sorted(index.boards) sortOrder.wrappedValue.sorted(index.boards)
} }
/// Shown only once there is something to sort an empty or not-yet-loaded list has no rows
/// for the segments to reorder.
@ToolbarContentBuilder
private var sortToolbarItem: some ToolbarContent {
if case .ready = index.phase, !index.boards.isEmpty {
ToolbarItem(placement: .bottomBar) {
Picker("Sort", selection: sortOrder) {
Text("Name").tag(BoardSortOrder.name)
Text("Recent").tag(BoardSortOrder.recent)
}
.pickerStyle(.segmented)
}
}
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
switch index.phase { switch index.phase {
@@ -93,14 +111,6 @@ struct BoardsTabView: View {
) )
} }
} else { } else {
Picker("Sort", selection: sortOrder) {
Text("Name").tag(BoardSortOrder.name)
Text("Recent").tag(BoardSortOrder.recent)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
ForEach(sortedBoards) { board in ForEach(sortedBoards) { board in
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) { NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
BoardSummaryRow(board: board) BoardSummaryRow(board: board)
+20 -10
View File
@@ -6,7 +6,7 @@ import SwiftUI
/// `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. A segmented
/// control above the card rows lets the list be sorted by name or by most recent change, on top /// 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. /// of the lane's own manual arrangement, remembered across launches.
struct LaneScreen: View { struct LaneScreen: View {
let boardRoot: URL let boardRoot: URL
@@ -47,6 +47,9 @@ 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)
@@ -88,6 +91,22 @@ struct LaneScreen: View {
return title 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: .bottomBar) {
Picker("Sort", selection: sortOrder) {
Text("Manual").tag(ItemSortOrder.manual)
Text("Name").tag(ItemSortOrder.name)
Text("Recent").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
}
}
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if let lane { if let lane {
@@ -106,15 +125,6 @@ struct LaneScreen: View {
description: Text("Add a card to this lane.") description: Text("Add a card to this lane.")
) )
} else { } 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 `Lane.cards` is already in display // The trusted order is `.manual`'s base `Lane.cards` is already in display
// order, exactly as the lane list trusts `BoardModel.lanes` and `.name`/ // 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 // `.recent` layer a display-only re-sort on top, never touching that base or