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:
@@ -1,5 +1,9 @@
|
|||||||
**August 2026**
|
**August 2026**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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 new control above the list.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ struct BoardSummary: Identifiable, Sendable, Equatable {
|
|||||||
/// no `title` key is a normal board rather than an untitled one.
|
/// no `title` key is a normal board rather than an untitled one.
|
||||||
let title: String
|
let title: String
|
||||||
|
|
||||||
|
/// The board `index.md`'s `icon:`, verbatim — an SF Symbol name, or `nil` when unset or the file
|
||||||
|
/// couldn't be read. Unlike `title`, there is no fallback: a board with no icon shows none.
|
||||||
|
let icon: String?
|
||||||
|
|
||||||
|
/// The board `index.md`'s `iconColor:`, verbatim — a `CardPalette` swatch name, or `nil` on the
|
||||||
|
/// same terms as `icon`.
|
||||||
|
let iconColor: String?
|
||||||
|
|
||||||
/// Lanes the loader would show. `nil` where the package is not materialized enough to count —
|
/// Lanes the loader would show. `nil` where the package is not materialized enough to count —
|
||||||
/// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero.
|
/// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero.
|
||||||
let laneCount: Int?
|
let laneCount: Int?
|
||||||
@@ -172,6 +180,8 @@ enum BoardSummaryScanner {
|
|||||||
return BoardSummary(
|
return BoardSummary(
|
||||||
rootURL: entry.rootURL,
|
rootURL: entry.rootURL,
|
||||||
title: fallbackTitle,
|
title: fallbackTitle,
|
||||||
|
icon: nil,
|
||||||
|
iconColor: nil,
|
||||||
laneCount: nil,
|
laneCount: nil,
|
||||||
cardCount: nil,
|
cardCount: nil,
|
||||||
modified: entry.modified,
|
modified: entry.modified,
|
||||||
@@ -181,7 +191,8 @@ enum BoardSummaryScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName)
|
let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName)
|
||||||
let title = readTitle(at: indexURL) ?? fallbackTitle
|
let fields = readIndexFields(at: indexURL)
|
||||||
|
let title = fields.title ?? fallbackTitle
|
||||||
|
|
||||||
// An unreadable board `index.md` means this is not a board the loader would open — a package
|
// An unreadable board `index.md` means this is not a board the loader would open — a package
|
||||||
// still arriving, or one whose root file is genuinely broken. Either way a count would be a
|
// still arriving, or one whose root file is genuinely broken. Either way a count would be a
|
||||||
@@ -190,6 +201,8 @@ enum BoardSummaryScanner {
|
|||||||
return BoardSummary(
|
return BoardSummary(
|
||||||
rootURL: entry.rootURL,
|
rootURL: entry.rootURL,
|
||||||
title: title,
|
title: title,
|
||||||
|
icon: fields.icon,
|
||||||
|
iconColor: fields.iconColor,
|
||||||
laneCount: nil,
|
laneCount: nil,
|
||||||
cardCount: nil,
|
cardCount: nil,
|
||||||
modified: entry.modified,
|
modified: entry.modified,
|
||||||
@@ -208,6 +221,8 @@ enum BoardSummaryScanner {
|
|||||||
return BoardSummary(
|
return BoardSummary(
|
||||||
rootURL: entry.rootURL,
|
rootURL: entry.rootURL,
|
||||||
title: title,
|
title: title,
|
||||||
|
icon: fields.icon,
|
||||||
|
iconColor: fields.iconColor,
|
||||||
laneCount: lanes,
|
laneCount: lanes,
|
||||||
cardCount: cards,
|
cardCount: cards,
|
||||||
modified: entry.modified,
|
modified: entry.modified,
|
||||||
@@ -216,18 +231,22 @@ enum BoardSummaryScanner {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The board title as written, or `nil` where the file is absent, is not UTF-8, has no
|
/// The board `index.md`'s `title:`/`icon:`/`iconColor:`, read together since they come from the
|
||||||
/// frontmatter, or carries no usable `title:` — every one of which is the folder-name fallback.
|
/// same one-file parse. `title` is `nil` where the file is absent, is not UTF-8, has no
|
||||||
private nonisolated static func readTitle(at indexURL: URL) -> String? {
|
/// frontmatter, or carries no usable `title:` — every one of which is the folder-name fallback in
|
||||||
|
/// `scan`. `icon`/`iconColor` are `nil` on the same unreadable-file terms, and also whenever the
|
||||||
|
/// document simply doesn't set them — there is no fallback for either.
|
||||||
|
private nonisolated static func readIndexFields(
|
||||||
|
at indexURL: URL
|
||||||
|
) -> (title: String?, icon: String?, iconColor: String?) {
|
||||||
guard let data = try? Data(contentsOf: indexURL),
|
guard let data = try? Data(contentsOf: indexURL),
|
||||||
let text = String(data: data, encoding: .utf8),
|
let text = String(data: data, encoding: .utf8),
|
||||||
let document = try? FrontmatterDocument.parse(text),
|
let document = try? FrontmatterDocument.parse(text)
|
||||||
let title = document.title.value,
|
|
||||||
!title.isEmpty
|
|
||||||
else {
|
else {
|
||||||
return nil
|
return (nil, nil, nil)
|
||||||
}
|
}
|
||||||
return title
|
let title = document.title.value
|
||||||
|
return (title?.isEmpty == false ? title : nil, document.icon.value, document.iconColor.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the
|
/// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the
|
||||||
|
|||||||
@@ -5,15 +5,30 @@ 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.
|
/// 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 {
|
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.manual.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 `.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 {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.navigationTitle(title)
|
.navigationTitle(title)
|
||||||
@@ -51,6 +66,13 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
switch session.phase {
|
switch session.phase {
|
||||||
@@ -101,9 +123,19 @@ struct BoardScreen: View {
|
|||||||
description: Text("Add a lane to start organizing cards.")
|
description: Text("Add a lane to start organizing cards.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Already in display order (`BoardModel.lanes`'s own contract) — the loader's
|
Picker("Sort", selection: sortOrder) {
|
||||||
// `Ranks.sortedForDisplay` is trusted rather than re-sorted here.
|
Text("Manual").tag(ItemSortOrder.manual)
|
||||||
ForEach(snapshot.lanes) { lane in
|
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)) {
|
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
|
||||||
LaneSummaryRow(lane: lane)
|
LaneSummaryRow(lane: lane)
|
||||||
}
|
}
|
||||||
@@ -114,11 +146,13 @@ 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 {
|
private struct LaneSummaryRow: View {
|
||||||
let lane: Lane
|
let lane: Lane
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
ItemIconView(icon: lane.icon.value, iconColor: lane.iconColor.value)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(displayTitle)
|
Text(displayTitle)
|
||||||
Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")")
|
Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")")
|
||||||
@@ -126,6 +160,7 @@ private struct LaneSummaryRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var displayTitle: String {
|
private var displayTitle: String {
|
||||||
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
|
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
|
||||||
|
|||||||
@@ -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,
|
/// One board row: its icon when it has one, title, lane/card counts, modified date, a marker for a
|
||||||
/// and — while the package is not fully current — a download-state subtitle in place of the counts a
|
/// board that is not in iCloud, and — while the package is not fully current — a download-state
|
||||||
/// shallow scan cannot yet answer.
|
/// 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
|
/// 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.
|
/// row would be noise; "Local" is the exception, and the exception is what a marker is for.
|
||||||
@@ -160,6 +160,8 @@ private struct BoardSummaryRow: View {
|
|||||||
let board: BoardSummary
|
let board: BoardSummary
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
ItemIconView(icon: board.icon, iconColor: board.iconColor)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(board.title)
|
Text(board.title)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -172,6 +174,7 @@ private struct BoardSummaryRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var subtitle: String {
|
private var subtitle: String {
|
||||||
switch board.download {
|
switch board.download {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ 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.
|
/// 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 {
|
struct LaneScreen: View {
|
||||||
let boardRoot: URL
|
let boardRoot: URL
|
||||||
let laneID: ItemID
|
let laneID: ItemID
|
||||||
@@ -16,8 +18,21 @@ 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.manual.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 `.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? {
|
private var lane: Lane? {
|
||||||
session.snapshot?.lanes.first { $0.id == laneID }
|
session.snapshot?.lanes.first { $0.id == laneID }
|
||||||
}
|
}
|
||||||
@@ -66,6 +81,13 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var content: some View {
|
private var content: some View {
|
||||||
if let lane {
|
if let lane {
|
||||||
@@ -84,9 +106,20 @@ struct LaneScreen: View {
|
|||||||
description: Text("Add a card to this lane.")
|
description: Text("Add a card to this lane.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Already in display order (`Lane.cards`'s own contract) — trusted rather than
|
Picker("Sort", selection: sortOrder) {
|
||||||
// re-sorted here, exactly as the lane list trusts `BoardModel.lanes`.
|
Text("Manual").tag(ItemSortOrder.manual)
|
||||||
ForEach(lane.cards) { card in
|
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)) {
|
NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
|
||||||
CardSummaryRow(card: card)
|
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
|
/// **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
|
/// `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 {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(displayTitle)
|
Text(displayTitle)
|
||||||
if !card.attachments.isEmpty {
|
if !card.attachments.isEmpty {
|
||||||
@@ -186,11 +220,6 @@ private struct CardSummaryRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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" }
|
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
|
||||||
return title
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
private var iconTint: Color {
|
|
||||||
card.iconColor.value.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The leading icon a list row renders when its item has one — the symbol name straight from
|
||||||
|
/// frontmatter, tinted through the palette with `.secondary` standing in for any name the
|
||||||
|
/// palette does not know. Decorative: the row's text is the accessible content.
|
||||||
|
struct ItemIconView: View {
|
||||||
|
let icon: String?
|
||||||
|
let iconColor: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let icon, !icon.isEmpty {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unrecognized colour name falls back to `.secondary` rather than guessing — the same
|
||||||
|
/// leniency `CardPalette.color(named:in:)` documents for itself.
|
||||||
|
private var tint: Color {
|
||||||
|
iconColor.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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