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
231 lines
9.8 KiB
Swift
231 lines
9.8 KiB
Swift
import SwiftUI
|
|
|
|
/// The card list for one lane — `BoardRoute.cards`'s destination.
|
|
///
|
|
/// 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 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
|
|
|
|
@Environment(BoardIndexStore.self) private var index
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@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<ItemSortOrder> {
|
|
Binding(
|
|
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .manual },
|
|
set: { sortOrderRaw = $0.rawValue }
|
|
)
|
|
}
|
|
|
|
private var lane: Lane? {
|
|
session.snapshot?.lanes.first { $0.id == laneID }
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
.navigationTitle(navigationTitle)
|
|
.toolbar {
|
|
if lane != nil {
|
|
Button("New Card", systemImage: "plus") {
|
|
isPresentingNewCard = true
|
|
}
|
|
}
|
|
}
|
|
.task { session.open() }
|
|
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
|
|
createCard(titled: title.isEmpty ? nil : title)
|
|
}
|
|
.confirmationDialog(
|
|
"Move Card",
|
|
isPresented: Binding(get: { cardPendingMove != nil }, set: { if !$0 { cardPendingMove = nil } }),
|
|
titleVisibility: .visible
|
|
) {
|
|
ForEach(otherLanes) { destination in
|
|
Button(displayTitle(of: destination)) {
|
|
if let cardID = cardPendingMove {
|
|
move(cardID, to: destination.id)
|
|
}
|
|
cardPendingMove = nil
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { cardPendingMove = nil }
|
|
}
|
|
}
|
|
|
|
private var navigationTitle: String {
|
|
lane.map(displayTitle(of:)) ?? "Lane"
|
|
}
|
|
|
|
private var otherLanes: [Lane] {
|
|
(session.snapshot?.lanes ?? []).filter { $0.id != laneID }
|
|
}
|
|
|
|
private func displayTitle(of lane: Lane) -> String {
|
|
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
|
|
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 {
|
|
List {
|
|
if session.lastError != nil {
|
|
Section {
|
|
Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if lane.cards.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Cards Yet",
|
|
systemImage: "rectangle.on.rectangle",
|
|
description: Text("Add a card to this lane.")
|
|
)
|
|
} 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
|
|
// 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)
|
|
}
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
trash(card.id)
|
|
} label: {
|
|
Label("Trash", systemImage: "trash")
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading) {
|
|
if !otherLanes.isEmpty {
|
|
Button {
|
|
cardPendingMove = card.id
|
|
} label: {
|
|
Label("Move", systemImage: "arrow.right.arrow.left")
|
|
}
|
|
.tint(.blue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.refreshable { session.reload() }
|
|
} else if case .ready = session.phase {
|
|
// The lane is gone from a `.ready` snapshot — deleted elsewhere while this screen was
|
|
// open. Nothing left to list; back out rather than leave a dead screen on top of the
|
|
// stack.
|
|
ContentUnavailableView("Lane Removed", systemImage: "trash", description: Text("This lane was deleted."))
|
|
.task { dismiss() }
|
|
} else {
|
|
ProgressView("Opening lane")
|
|
}
|
|
}
|
|
|
|
private func createCard(titled title: String?) {
|
|
let laneID = self.laneID
|
|
Task {
|
|
// The closure's throws type must be spelled out — a trailing closure literal does not
|
|
// pick up `perform`'s `throws(BoardWriteError)` from context alone.
|
|
await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in
|
|
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
|
return try BoardWriter.createCard(inLane: laneFolder, title: title)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func trash(_ cardID: ItemID) {
|
|
let laneID = self.laneID
|
|
Task {
|
|
await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in
|
|
let cardFolder = root
|
|
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
|
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
|
return try BoardWriter.deleteCardToTrash(at: cardFolder, inBoard: root)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func move(_ cardID: ItemID, to destinationLaneID: ItemID) {
|
|
let laneID = self.laneID
|
|
Task {
|
|
await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in
|
|
let sourceFolder = root
|
|
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
|
.appendingPathComponent(cardID.rawValue, isDirectory: true)
|
|
let destinationParent = root.appendingPathComponent(destinationLaneID.rawValue, isDirectory: true)
|
|
// `order: nil` — append at the end of the destination lane's visible cards, the
|
|
// same placement a hand-created card gets.
|
|
return try BoardWriter.moveItem(
|
|
at: sourceFolder,
|
|
toParent: destinationParent,
|
|
sourceBoardRoot: root,
|
|
destinationBoardRoot: root,
|
|
order: nil
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// carries cheaply) stands in as the row's "cheap material" instead.
|
|
private struct CardSummaryRow: View {
|
|
let card: Card
|
|
|
|
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 {
|
|
Label("\(card.attachments.count)", systemImage: "paperclip")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var displayTitle: String {
|
|
guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" }
|
|
return title
|
|
}
|
|
}
|