Files
lanework/KanbanMobile/Screens/LaneScreen.swift
T
rzen 871083e5ca The tab bar retires — boards are the app, settings behind the gear, recency the default order
The boards stack becomes the root screen: Settings moves from its own
tab to a sheet behind a leading gearshape button, the sort pickers trade
their text segments for symbols (hand.draw, textformat.abc, clock) with
the words kept for accessibility, and every list now opens sorted by
most recent change. UI tests re-aimed at the gear button and Done-button
scoping fixed for the sheet-over-sheet nav bars — 4/4 green in the
simulator.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 13:07:44 -04:00

242 lines
10 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 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.
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.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<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent },
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
}
}
}
.toolbar {
sortToolbarItem
}
.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
}
/// 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 {
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 {
// 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
}
}