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:
@@ -0,0 +1,122 @@
|
||||
import SwiftUI
|
||||
|
||||
/// One board's own settings, reached by swiping its row in the boards list.
|
||||
///
|
||||
/// **Reached from the list on purpose.** Its one setting today — which home the board is stored in —
|
||||
/// is the only thing in the app that changes a board's URL, and a board's URL is its identity
|
||||
/// (`BoardSummary.id`). Presenting this from the list means no screen is holding the old identity
|
||||
/// when it retires; the sheet dismisses itself on success and the row underneath is already the new
|
||||
/// board.
|
||||
struct BoardSettingsSheet: View {
|
||||
|
||||
/// A snapshot, taken when the swipe action fired. It goes stale exactly once — at the moment a
|
||||
/// move succeeds — and the sheet is gone by then.
|
||||
let board: BoardSummary
|
||||
|
||||
@Environment(BoardIndexStore.self) private var index
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isConfirmingMove = false
|
||||
@State private var isMoving = false
|
||||
|
||||
/// The last move's refusal, shown in the section footer rather than as an alert: the failure is
|
||||
/// about the row the user is looking at, and every one of them is a "try again" rather than a
|
||||
/// decision to make.
|
||||
@State private var failure: String?
|
||||
|
||||
/// There are two homes, so there is one destination and the button can name it instead of asking.
|
||||
private var destination: BoardLocation {
|
||||
board.location == .icloud ? .local : .icloud
|
||||
}
|
||||
|
||||
/// iCloud is a destination only when it resolved. In practice a run with no iCloud lists no iCloud
|
||||
/// boards at all — the query is dead — so this guards a state the app should not be able to reach
|
||||
/// rather than one it routinely does.
|
||||
private var canMove: Bool {
|
||||
destination == .local || index.cloudUnavailable == nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Stored In", value: board.location.name)
|
||||
Button {
|
||||
isConfirmingMove = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Move to \(destination.name)")
|
||||
if isMoving {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isMoving || !canMove)
|
||||
} header: {
|
||||
Text("Location")
|
||||
} footer: {
|
||||
Text(footer)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Board Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
confirmationTitle,
|
||||
isPresented: $isConfirmingMove,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
// Destructive only in the direction that removes protection: a board leaving iCloud
|
||||
// stops syncing and stops being backed up by anything but this phone. The return trip
|
||||
// only adds, so it is an ordinary button.
|
||||
Button("Move", role: destination == .local ? .destructive : nil) { move() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(confirmationMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: String {
|
||||
if let failure { return failure }
|
||||
if !canMove { return "Sign into iCloud to move boards there." }
|
||||
switch board.location {
|
||||
case .icloud: return "This board is in iCloud Drive and syncs to your other devices."
|
||||
case .local: return "This board is on this iPhone only."
|
||||
}
|
||||
}
|
||||
|
||||
private var confirmationTitle: String {
|
||||
destination == .local
|
||||
? "Move “\(board.title)” out of iCloud?"
|
||||
: "Move “\(board.title)” to iCloud?"
|
||||
}
|
||||
|
||||
private var confirmationMessage: String {
|
||||
destination == .local
|
||||
? "It will stop syncing and live only on this iPhone. Your other devices won't see it."
|
||||
: "It will sync to your other devices."
|
||||
}
|
||||
|
||||
private func move() {
|
||||
isMoving = true
|
||||
failure = nil
|
||||
Task {
|
||||
let outcome = await index.relocateBoard(at: board.rootURL, to: destination)
|
||||
isMoving = false
|
||||
switch outcome {
|
||||
case .success:
|
||||
// The list underneath has already refreshed, and this sheet's `board` names a package
|
||||
// that is no longer there.
|
||||
dismiss()
|
||||
case let .failure(reason):
|
||||
failure = reason.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,10 @@ struct SettingsTabView: View {
|
||||
LabeledContent("Backup") {
|
||||
ProgressView()
|
||||
}
|
||||
case .unavailable, .ready:
|
||||
// `.ready` can appear briefly here too: `backupController` lags one runloop
|
||||
// turn behind `index.home` resolving (MobileApp's `.task(id:)`).
|
||||
case .ready:
|
||||
// `.ready` is both cases now: the run with no iCloud at all, and the runloop turn
|
||||
// `backupController` lags `index.home` by (MobileApp's `.task(id:)`). Backup is
|
||||
// still iCloud-only — a known gap now that local boards exist.
|
||||
Label("Backup and restore need iCloud Drive.", systemImage: "icloud.slash")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user