Files
lanework/KanbanMobile/Screens/BoardsTabView.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

226 lines
9.3 KiB
Swift

import SwiftUI
/// The root of the boards navigation stack: board list → lanes → cards → card detail
/// (`BoardRoute`'s destinations) — and, now that the tab bar is gone, the app's root screen.
/// Settings is presented from the leading gear button rather than a tab of its own.
///
/// 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
@State private var isPresentingSettings = 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.recent.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 {
// Unconditional — settings (About, license, changelog) must stay reachable
// even while the index is still loading or the board list is empty.
ToolbarItem(placement: .topBarLeading) {
Button("Settings", systemImage: "gearshape") {
isPresentingSettings = true
}
}
}
.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)
}
.sheet(isPresented: $isPresentingSettings) {
SettingsScreen()
}
}
/// 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<BoardSortOrder> {
Binding(
get: { BoardSortOrder(rawValue: sortOrderRaw) ?? .recent },
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: .principal) {
Picker("Sort", selection: sortOrder) {
Label("Name", systemImage: "textformat.abc").tag(BoardSortOrder.name)
Label("Recent", systemImage: "clock").tag(BoardSortOrder.recent)
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
}
}
}
@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)))"
}
}