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
123 lines
4.7 KiB
Swift
123 lines
4.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|