diff --git a/KanbanMobile/CHANGELOG.md b/KanbanMobile/CHANGELOG.md index 82bb5b5..f22c26d 100644 --- a/KanbanMobile/CHANGELOG.md +++ b/KanbanMobile/CHANGELOG.md @@ -1,5 +1,7 @@ **August 2026** +Press and hold a lane or card to drag it into a new order when sorting is set to manual. + 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. diff --git a/KanbanMobile/Screens/BoardScreen.swift b/KanbanMobile/Screens/BoardScreen.swift index e933d87..2a63cc6 100644 --- a/KanbanMobile/Screens/BoardScreen.swift +++ b/KanbanMobile/Screens/BoardScreen.swift @@ -7,7 +7,9 @@ import SwiftUI /// 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. +/// 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 { let boardRoot: URL @@ -151,10 +153,68 @@ struct BoardScreen: View { 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) } } .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. + /// + /// `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 + /// `Ranks.insertionRank` mints a rank against — the same index convention `BoardStore.moveLane` + /// uses on the Mac. The happy path asks for the midpoint between the two neighbours the lane + /// lands between; on the rare board where that gap is already exhausted + /// (01-storage-format.md § Ordering), there is no `HealScheduler` to reach for — that engine + /// lives in `Kanban/LiveStore`, which this target does not compile — so the fallback renumbers + /// every lane to a fresh whole-multiple-of-1024 ladder in `manualOrder`'s own order, one + /// `BoardWriter.moveItem` reorder call per lane inside the same `session.perform` bracket. Both + /// paths are same-parent reorders, so neither stamps `modified` (the reorders-don't-stamp rule). + private func moveRows(from source: IndexSet, to destination: Int) { + guard let sourceIndex = source.first, + let snapshot = session.snapshot, + snapshot.lanes.indices.contains(sourceIndex) + else { return } + + var manualOrder = snapshot.lanes + let moved = manualOrder[sourceIndex] + manualOrder.move(fromOffsets: IndexSet(integer: sourceIndex), toOffset: destination) + guard let landingIndex = manualOrder.firstIndex(where: { $0.id == moved.id }) else { return } + + let siblingOrders = manualOrder.filter { $0.id != moved.id }.map(\.order) + // A `let` copy — the mutating `Array.move` above needs `manualOrder` as a `var`, but a + // `@Sendable` closure cannot capture a mutable var, and the fallback loop below needs the + // finished (post-move) arrangement, not the pre-move one. + let finalOrder = manualOrder + + Task { + await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in + if let rank = Ranks.insertionRank(amongVisible: siblingOrders, at: landingIndex) { + return try BoardWriter.moveItem( + at: root.appendingPathComponent(moved.id.rawValue, isDirectory: true), + toParent: root, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: rank + ) + } + for (lane, freshRank) in zip(finalOrder, Ranks.renumbered(count: finalOrder.count)) { + _ = try BoardWriter.moveItem( + at: root.appendingPathComponent(lane.id.rawValue, isDirectory: true), + toParent: root, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: freshRank + ) + } + return MoveResult(id: moved.id, reminted: []) + } + } + } } /// One lane row: its icon when it has one, title, and its card count. diff --git a/KanbanMobile/Screens/LaneScreen.swift b/KanbanMobile/Screens/LaneScreen.swift index 9afc68d..5683462 100644 --- a/KanbanMobile/Screens/LaneScreen.swift +++ b/KanbanMobile/Screens/LaneScreen.swift @@ -7,7 +7,9 @@ import SwiftUI /// 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. +/// 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 { let boardRoot: URL let laneID: ItemID @@ -152,6 +154,9 @@ 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) } } .refreshable { session.reload() } @@ -210,6 +215,64 @@ 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. + /// + /// `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 + /// `Ranks.insertionRank` mints a rank against — the same index convention `BoardStore.moveLane` + /// uses on the Mac for lanes. The happy path asks for the midpoint between the two neighbours the + /// card lands between; on the rare lane where that gap is already exhausted + /// (01-storage-format.md § Ordering), there is no `HealScheduler` to reach for — that engine + /// lives in `Kanban/LiveStore`, which this target does not compile — so the fallback renumbers + /// every card in the lane to a fresh whole-multiple-of-1024 ladder in `manualOrder`'s own order, + /// one `BoardWriter.moveItem` reorder call per card inside the same `session.perform` bracket. + /// Both paths are same-parent reorders, so neither stamps `modified` (the reorders-don't-stamp + /// rule). + private func moveRows(from source: IndexSet, to destination: Int) { + guard let sourceIndex = source.first, + let lane, + lane.cards.indices.contains(sourceIndex) + else { return } + + var manualOrder = lane.cards + let moved = manualOrder[sourceIndex] + manualOrder.move(fromOffsets: IndexSet(integer: sourceIndex), toOffset: destination) + guard let landingIndex = manualOrder.firstIndex(where: { $0.id == moved.id }) else { return } + + let siblingOrders = manualOrder.filter { $0.id != moved.id }.map(\.order) + let laneID = self.laneID + // A `let` copy — the mutating `Array.move` above needs `manualOrder` as a `var`, but a + // `@Sendable` closure cannot capture a mutable var, and the fallback loop below needs the + // finished (post-move) arrangement, not the pre-move one. + let finalOrder = manualOrder + + Task { + await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in + let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + if let rank = Ranks.insertionRank(amongVisible: siblingOrders, at: landingIndex) { + return try BoardWriter.moveItem( + at: laneFolder.appendingPathComponent(moved.id.rawValue, isDirectory: true), + toParent: laneFolder, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: rank + ) + } + for (card, freshRank) in zip(finalOrder, Ranks.renumbered(count: finalOrder.count)) { + _ = try BoardWriter.moveItem( + at: laneFolder.appendingPathComponent(card.id.rawValue, isDirectory: true), + toParent: laneFolder, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: freshRank + ) + } + return MoveResult(id: moved.id, reminted: []) + } + } + } } /// One card row: its icon when it has one, title, and an attachment-count hint.