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 { 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)))" } }