From e8791b239dbf93463fd4249099076189c769491c Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 12:34:10 -0400 Subject: [PATCH] =?UTF-8?q?The=20phone's=20rows=20learn=20their=20faces=20?= =?UTF-8?q?and=20two=20more=20orders=20=E2=80=94=20icons=20everywhere,=20l?= =?UTF-8?q?anes=20and=20cards=20sortable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KanbanMobile/CHANGELOG.md | 4 ++ KanbanMobile/Cloud/BoardSummary.swift | 37 ++++++++++++---- KanbanMobile/Screens/BoardScreen.swift | 55 ++++++++++++++++++----- KanbanMobile/Screens/BoardsTabView.swift | 25 ++++++----- KanbanMobile/Screens/LaneScreen.swift | 53 ++++++++++++++++------ KanbanMobile/UI/ItemIconView.swift | 23 ++++++++++ KanbanMobile/UI/ItemSortOrder.swift | 56 ++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 44 deletions(-) create mode 100644 KanbanMobile/UI/ItemIconView.swift create mode 100644 KanbanMobile/UI/ItemSortOrder.swift diff --git a/KanbanMobile/CHANGELOG.md b/KanbanMobile/CHANGELOG.md index a228f67..ae06350 100644 --- a/KanbanMobile/CHANGELOG.md +++ b/KanbanMobile/CHANGELOG.md @@ -1,5 +1,9 @@ **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. Version 1.0: Lanework comes to iPhone — browse your boards from iCloud Drive, move cards between lanes, and edit them on the go. diff --git a/KanbanMobile/Cloud/BoardSummary.swift b/KanbanMobile/Cloud/BoardSummary.swift index 0f587b0..30b1894 100644 --- a/KanbanMobile/Cloud/BoardSummary.swift +++ b/KanbanMobile/Cloud/BoardSummary.swift @@ -17,6 +17,14 @@ struct BoardSummary: Identifiable, Sendable, Equatable { /// no `title` key is a normal board rather than an untitled one. 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 — /// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero. let laneCount: Int? @@ -172,6 +180,8 @@ enum BoardSummaryScanner { return BoardSummary( rootURL: entry.rootURL, title: fallbackTitle, + icon: nil, + iconColor: nil, laneCount: nil, cardCount: nil, modified: entry.modified, @@ -181,7 +191,8 @@ enum BoardSummaryScanner { } 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 // 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( rootURL: entry.rootURL, title: title, + icon: fields.icon, + iconColor: fields.iconColor, laneCount: nil, cardCount: nil, modified: entry.modified, @@ -208,6 +221,8 @@ enum BoardSummaryScanner { return BoardSummary( rootURL: entry.rootURL, title: title, + icon: fields.icon, + iconColor: fields.iconColor, laneCount: lanes, cardCount: cards, 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 - /// frontmatter, or carries no usable `title:` — every one of which is the folder-name fallback. - private nonisolated static func readTitle(at indexURL: URL) -> String? { + /// The board `index.md`'s `title:`/`icon:`/`iconColor:`, read together since they come from the + /// same one-file parse. `title` is `nil` where the file is absent, is not UTF-8, has no + /// 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), let text = String(data: data, encoding: .utf8), - let document = try? FrontmatterDocument.parse(text), - let title = document.title.value, - !title.isEmpty + let document = try? FrontmatterDocument.parse(text) 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 diff --git a/KanbanMobile/Screens/BoardScreen.swift b/KanbanMobile/Screens/BoardScreen.swift index 0d5c2ad..4c48fb0 100644 --- a/KanbanMobile/Screens/BoardScreen.swift +++ b/KanbanMobile/Screens/BoardScreen.swift @@ -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 { + 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) + } } } diff --git a/KanbanMobile/Screens/BoardsTabView.swift b/KanbanMobile/Screens/BoardsTabView.swift index c325c3f..5b0f54d 100644 --- a/KanbanMobile/Screens/BoardsTabView.swift +++ b/KanbanMobile/Screens/BoardsTabView.swift @@ -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) } } diff --git a/KanbanMobile/Screens/LaneScreen.swift b/KanbanMobile/Screens/LaneScreen.swift index b0ae93d..4c9a82c 100644 --- a/KanbanMobile/Screens/LaneScreen.swift +++ b/KanbanMobile/Screens/LaneScreen.swift @@ -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 { + 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 - } } diff --git a/KanbanMobile/UI/ItemIconView.swift b/KanbanMobile/UI/ItemIconView.swift new file mode 100644 index 0000000..41b60d7 --- /dev/null +++ b/KanbanMobile/UI/ItemIconView.swift @@ -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 + } +} diff --git a/KanbanMobile/UI/ItemSortOrder.swift b/KanbanMobile/UI/ItemSortOrder.swift new file mode 100644 index 0000000..4fd9f47 --- /dev/null +++ b/KanbanMobile/UI/ItemSortOrder.swift @@ -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( + _ 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) + } + } +}