A segmented control above the mobile board list, remembered across launches: Name keeps the index's stable title order, Recent sorts by content-change date descending with undated boards last and ties held stable against refresh reshuffling. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
198 lines
8.0 KiB
Swift
198 lines
8.0 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 above the rows 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
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
|
|
@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 {
|
|
Picker("Sort", selection: sortOrder) {
|
|
Text("Name").tag(BoardSortOrder.name)
|
|
Text("Recent").tag(BoardSortOrder.recent)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
|
|
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: 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 {
|
|
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)))"
|
|
}
|
|
}
|