The board chooses where it lives — swipe-open settings move it between iCloud and this iPhone

A trailing swipe on a board row opens Board Settings, whose first setting is location: iCloud or Local, with a confirmed move to the other side — destructive-styled only outbound, because leaving iCloud is the direction that sheds protection. The move is setUbiquitous against the real container and a coordinated move under the DEBUG stand-in; evacuation sweeps materialization first and refuses honestly while content is still downloading. The local home is the sandbox Documents folder, published to the Files app, so a local board is still a folder the user owns.

With a second home the iCloud wall softens (user-ruled 2026-08-08): the index always reaches ready, cloud unavailability becomes an inline notice with a retry, creates land locally when there is no account, and LANEWORK_FORCE_NO_ICLOUD makes that state reproducible in tests regardless of the machine's sign-in. Known gap, now user-reachable: backup remains iCloud-only, so local boards sit outside it.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 10:56:39 -04:00
parent 1d97a2931c
commit 1c16bb4c38
10 changed files with 910 additions and 134 deletions
+89 -31
View File
@@ -3,13 +3,19 @@ import SwiftUI
/// The root of the boards navigation stack: board list lanes cards card detail
/// (`BoardRoute`'s destinations).
///
/// Renders all three of `BoardIndexStore.Phase` the property the placeholder this replaces
/// existed to prove, and the split this screen keeps.
/// 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.
struct BoardsTabView: View {
@Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewBoard = false
/// 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
@@ -33,9 +39,20 @@ struct BoardsTabView: View {
}
}
.task { index.start() }
.titlePromptAlert("New Board", isPresented: $isPresentingNewBoard, placeholder: "Board Title") { title in
.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)
}
}
@ViewBuilder
@@ -44,48 +61,89 @@ struct BoardsTabView: View {
case .idle, .loading:
ProgressView("Looking for boards")
case let .unavailable(reason):
// The hard-iCloud-requirement wall. `reason` distinguishes "sign in" from "the container
// did not resolve", which are different asks of the user.
ContentUnavailableView {
Label("iCloud Required", systemImage: "icloud.slash")
} description: {
Text(reason == .noAccount
? "Sign into iCloud in Settings to use Lanework."
: "Lanework can't reach its iCloud Drive folder right now.")
} actions: {
Button("Try Again") { index.start() }
}
case .ready where index.boards.isEmpty:
ContentUnavailableView(
"No Boards Yet",
systemImage: "rectangle.stack",
description: Text("Boards in your iCloud Drive will appear here.")
)
case .ready:
List(index.boards) { board in
NavigationLink(value: BoardRoute.lanes(boardRoot: board.rootURL)) {
BoardSummaryRow(board: board)
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(index.boards) { 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, and while the package is not fully
/// current a download-state subtitle in place of the counts a shallow scan cannot yet answer.
/// 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)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 6) {
if board.location == .local {
Label("Local", systemImage: "iphone")
}
Text(subtitle)
}
.font(.caption)
.foregroundStyle(.secondary)
}
}