Files
lanework/KanbanMobile/Screens/BoardsTabView.swift
T
rzen 49b2f4db6e The sort control leaves the list — three pickers settle into the bottom bar
Same segments, same persistence; the picker now rides each screen's
toolbar as a bottom-bar item shown only when there are rows to reorder,
instead of masquerading as the first row of the list it sorts.

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

211 lines
8.5 KiB
Swift

import SwiftUI
/// The root of the boards navigation stack: board list → lanes → cards → card detail
/// (`BoardRoute`'s destinations).
///
/// Renders both of `BoardIndexStore.Phase`'s live states and, above the list, the iCloud notice —
/// which is a row, not a wall (softened 2026-08-08). A phone with no account still has a device home
/// and therefore still has boards, so the missing half of the app is reported next to the half that
/// works rather than in place of it. A segmented control in the toolbar lets the list be sorted by
/// name or by most recent change, remembered across launches.
struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewBoard = false
/// Persisted as its raw value, not the enum itself — `AppStorage` needs a property-list type, and
/// the raw `String` is exactly what `BoardSortOrder` promises to keep stable.
@AppStorage("boardListSortOrder") private var sortOrderRaw = BoardSortOrder.name.rawValue
/// The board whose settings sheet is up. A value, not a URL: the sheet renders the row's own
/// summary, and a successful move dismisses it before the stale copy could matter.
@State private var boardInSettings: BoardSummary?
var body: some View {
NavigationStack {
content
.navigationTitle("Boards")
.toolbar {
if case .ready = index.phase {
Button("New Board", systemImage: "plus") {
isPresentingNewBoard = true
}
}
}
.toolbar {
sortToolbarItem
}
.navigationDestination(for: BoardRoute.self) { route in
switch route {
case let .lanes(boardRoot):
BoardScreen(boardRoot: boardRoot)
case let .cards(boardRoot, laneID):
LaneScreen(boardRoot: boardRoot, laneID: laneID)
case let .card(boardRoot, laneID, cardID):
CardDetailScreen(boardRoot: boardRoot, laneID: laneID, cardID: cardID)
}
}
}
.task { index.start() }
.titlePromptAlert(
"New Board",
isPresented: $isPresentingNewBoard,
// Said only when it is news. With iCloud available the answer is the one every board
// already gives; without it, where the board lands is the thing the user most needs to
// know before naming it.
message: index.cloudUnavailable == nil ? nil : "This board will be created on this iPhone.",
placeholder: "Board Title"
) { title in
Task { await index.createBoard(titled: title) }
}
.sheet(item: $boardInSettings) { board in
BoardSettingsSheet(board: board)
}
}
/// The stored raw value as the enum, defaulting to `.name` 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<BoardSortOrder> {
Binding(
get: { BoardSortOrder(rawValue: sortOrderRaw) ?? .name },
set: { sortOrderRaw = $0.rawValue }
)
}
private var sortedBoards: [BoardSummary] {
sortOrder.wrappedValue.sorted(index.boards)
}
/// Shown only once there is something to sort — an empty or not-yet-loaded list has no rows
/// for the segments to reorder.
@ToolbarContentBuilder
private var sortToolbarItem: some ToolbarContent {
if case .ready = index.phase, !index.boards.isEmpty {
ToolbarItem(placement: .bottomBar) {
Picker("Sort", selection: sortOrder) {
Text("Name").tag(BoardSortOrder.name)
Text("Recent").tag(BoardSortOrder.recent)
}
.pickerStyle(.segmented)
}
}
}
@ViewBuilder
private var content: some View {
switch index.phase {
case .idle, .loading:
ProgressView("Looking for boards")
case .ready:
List {
if let reason = index.cloudUnavailable {
cloudNotice(reason)
}
if index.boards.isEmpty {
Section {
ContentUnavailableView(
"No Boards Yet",
systemImage: "rectangle.stack",
description: Text(emptyDescription)
)
}
} else {
ForEach(sortedBoards) { board in
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
BoardSummaryRow(board: board)
}
.swipeActions(edge: .trailing) {
// Neutral tint: this reveals a screen, it does not destroy anything, and
// the one destructive-looking thing behind it has its own confirmation.
Button("Settings", systemImage: "gearshape") {
boardInSettings = board
}
.tint(.gray)
}
}
}
}
.refreshable { index.refresh() }
}
}
private var emptyDescription: String {
index.cloudUnavailable == nil
? "Boards in your iCloud Drive will appear here."
: "New boards will be saved on this iPhone."
}
/// The demoted wall. Same two sentences it used to say full-screen, and the same retry button —
/// `start()` re-attempts the container resolution and nothing else, because everything else
/// already resolved.
private func cloudNotice(_ reason: CloudHomeUnavailable) -> some View {
Section {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("iCloud Unavailable")
Text(reason == .noAccount
? "Sign into iCloud in Settings to sync your boards."
: "Lanework can't reach its iCloud Drive folder right now.")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "icloud.slash")
}
Button("Try Again") { index.start() }
} footer: {
Text("Boards on this iPhone still work, and can be moved to iCloud later.")
}
}
}
/// 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.
private struct BoardSummaryRow: View {
let board: BoardSummary
var body: some View {
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)
}
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var subtitle: String {
switch board.download {
case .notDownloaded:
"Waiting to download"
case let .downloading(fraction):
fraction.map { "Downloading \(Int($0 * 100))%" } ?? "Downloading"
case .current, .unknown:
countsAndModified
}
}
private var countsAndModified: String {
let counts: String
if let lanes = board.laneCount, let cards = board.cardCount {
counts = "\(lanes) lane\(lanes == 1 ? "" : "s") · \(cards) card\(cards == 1 ? "" : "s")"
} else {
counts = "—"
}
guard let modified = board.modified else { return counts }
return "\(counts) · \(modified.formatted(.relative(presentation: .named)))"
}
}