diff --git a/KanbanMobile/CHANGELOG.md b/KanbanMobile/CHANGELOG.md index f22c26d..7186e90 100644 --- a/KanbanMobile/CHANGELOG.md +++ b/KanbanMobile/CHANGELOG.md @@ -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. diff --git a/KanbanMobile/Screens/BoardScreen.swift b/KanbanMobile/Screens/BoardScreen.swift index 2a63cc6..0234ea7 100644 --- a/KanbanMobile/Screens/BoardScreen.swift +++ b/KanbanMobile/Screens/BoardScreen.swift @@ -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 { - 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 diff --git a/KanbanMobile/Screens/LaneScreen.swift b/KanbanMobile/Screens/LaneScreen.swift index 5683462..66af34d 100644 --- a/KanbanMobile/Screens/LaneScreen.swift +++ b/KanbanMobile/Screens/LaneScreen.swift @@ -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 { - 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 diff --git a/KanbanMobile/UI/ItemSortOrder.swift b/KanbanMobile/UI/ItemSortOrder.swift deleted file mode 100644 index 4fd9f47..0000000 --- a/KanbanMobile/UI/ItemSortOrder.swift +++ /dev/null @@ -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( - _ 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) - } - } -}