Make the iCloud gate patient and branded
Stop giving up on a slow iCloud container: a freshly enabled iCloud Drive that reports nil while still provisioning is now polled patiently for up to ~10 minutes instead of failing fast to the "unavailable" screen. A nil ubiquity token (not signed into iCloud at all) still fails immediately, and the connecting screen reveals an escape hatch at 28s for users who'd rather jump to setup than keep waiting. The connecting and iCloud-required screens are now branded — a purple gradient with teal accents — and the spinner becomes a custom comet-arc ConnectingIndicator around an iCloud glyph. Connecting copy escalates with the wait so a slow first connect reads as steady progress. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
**June 2026**
|
||||
|
||||
Right after you turn on iCloud Drive, Workouts now waits for it to finish coming online instead of giving up and showing the "turn on iCloud Drive" screen too soon. The connecting screen also keeps you posted as it works, letting you know the first connect can take a moment.
|
||||
|
||||
When iCloud Drive is turned off, Workouts now shows a clearer, friendlier screen — explaining that your data lives privately in your own iCloud Drive, with no account or login, and walking you through turning iCloud Drive on.
|
||||
|
||||
During an Apple Watch workout, lowering your wrist now keeps the work or rest timer showing its most recent count (marked with a ~) instead of a cut-off "less than a minute".
|
||||
|
||||
Add a Workouts complication to your Apple Watch face — tap it to open the app straight from any watch face.
|
||||
|
||||
@@ -31,13 +31,20 @@ final class SyncEngine {
|
||||
private var monitorTask: Task<Void, Never>?
|
||||
private var connectAttempt = 0
|
||||
|
||||
/// How long `connect()` keeps polling for a still-provisioning iCloud
|
||||
/// container before falling to the end-of-the-line gate. Deliberately long
|
||||
/// (~10 min): as long as the user is signed into iCloud, a container that's
|
||||
/// slow to come online should never be misreported as unavailable. Impatient
|
||||
/// users bail sooner via the connecting screen's escape hatch (28s).
|
||||
private static let connectTimeoutSeconds: TimeInterval = 600
|
||||
|
||||
private var context: ModelContext { modelContainer.mainContext }
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.modelContainer = container
|
||||
}
|
||||
|
||||
// MARK: - Connection (deferred, time-boxed)
|
||||
// MARK: - Connection (deferred, patient)
|
||||
|
||||
func connect() async {
|
||||
guard iCloudStatus != .available else { return }
|
||||
@@ -46,29 +53,64 @@ final class SyncEngine {
|
||||
iCloudStatus = .checking
|
||||
log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)")
|
||||
|
||||
let url = await Task.detached {
|
||||
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
|
||||
// Definitive failure first: if the user isn't signed into iCloud at all,
|
||||
// no container is ever coming — go straight to the end-of-the-line gate
|
||||
// rather than spinning. (Signed-in-but-Drive-off still reports a token;
|
||||
// that case falls through to the patient poll below and the escape hatch.)
|
||||
let signedIn = await Task.detached {
|
||||
FileManager.default.ubiquityIdentityToken != nil
|
||||
}.value
|
||||
guard let containerURL = url else {
|
||||
log.error("connect[\(attempt)]: ubiquity container URL is nil → unavailable (iCloud Drive off, or container not provisioned)")
|
||||
if attempt == connectAttempt { iCloudStatus = .unavailable }
|
||||
guard attempt == connectAttempt else { return }
|
||||
guard signedIn else {
|
||||
log.error("connect[\(attempt)]: not signed into iCloud → unavailable")
|
||||
iCloudStatus = .unavailable
|
||||
return
|
||||
}
|
||||
|
||||
// Signed in, but the container may still be provisioning — common right
|
||||
// after enabling iCloud Drive. Keep polling patiently: we'd rather hold the
|
||||
// spinner than misreport a working account as unavailable. We only give up
|
||||
// after a considerable timeout; the user can bail sooner via the connecting
|
||||
// screen's escape hatch (which bumps connectAttempt and stops this loop).
|
||||
var resolved: URL?
|
||||
let deadline = Date().addingTimeInterval(Self.connectTimeoutSeconds)
|
||||
while resolved == nil {
|
||||
let url = await Task.detached {
|
||||
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
|
||||
}.value
|
||||
guard attempt == connectAttempt else { return }
|
||||
if let url {
|
||||
resolved = url
|
||||
break
|
||||
}
|
||||
if Date() >= deadline {
|
||||
log.error("connect[\(attempt)]: container still nil after \(Int(Self.connectTimeoutSeconds))s → unavailable")
|
||||
iCloudStatus = .unavailable
|
||||
return
|
||||
}
|
||||
log.info("connect[\(attempt)]: container nil — still provisioning, retrying")
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard attempt == connectAttempt else { return }
|
||||
}
|
||||
guard let containerURL = resolved else { return }
|
||||
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")
|
||||
|
||||
let fm = ICloudFileManager(containerURL: containerURL)
|
||||
|
||||
let timeout = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(20))
|
||||
// Safety net only: prepareDirectories is a local op that effectively never
|
||||
// blocks, but if the first container file op ever wedges we don't want an
|
||||
// eternal spinner. This is generous — it isn't the connect path's clock.
|
||||
let safety = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
|
||||
self.log.error("connect[\(attempt)]: timed out after 20s — first container file op blocked (dataless container?)")
|
||||
self.log.error("connect[\(attempt)]: prepareDirectories wedged 30s → unavailable")
|
||||
self.iCloudStatus = .unavailable
|
||||
}
|
||||
}
|
||||
log.info("connect[\(attempt)]: preparing directories…")
|
||||
await fm.prepareDirectories()
|
||||
timeout.cancel()
|
||||
safety.cancel()
|
||||
guard attempt == connectAttempt else { return }
|
||||
|
||||
self.fileManager = fm
|
||||
@@ -81,6 +123,16 @@ final class SyncEngine {
|
||||
cleanupOldStubs()
|
||||
}
|
||||
|
||||
/// Invoked from the connecting screen when the user chooses not to keep
|
||||
/// waiting. Bumps `connectAttempt` to stop the in-flight poll loop, then drops
|
||||
/// to the end-of-the-line gate (with its Try Again).
|
||||
func abandonWaiting() {
|
||||
guard iCloudStatus == .checking else { return }
|
||||
connectAttempt += 1
|
||||
iCloudStatus = .unavailable
|
||||
log.info("connect: abandoned by user → unavailable")
|
||||
}
|
||||
|
||||
// MARK: - Monitoring
|
||||
|
||||
private func startMonitoring(documentsURL: URL) {
|
||||
|
||||
@@ -10,18 +10,11 @@ struct RootGateView: View {
|
||||
Group {
|
||||
switch syncEngine.iCloudStatus {
|
||||
case .checking:
|
||||
ProgressView("Connecting to iCloud…")
|
||||
ICloudCheckingView()
|
||||
case .available:
|
||||
ContentView()
|
||||
case .unavailable:
|
||||
ContentUnavailableView {
|
||||
Label("iCloud Required", systemImage: "icloud.slash")
|
||||
} description: {
|
||||
Text("Sign in to iCloud in Settings to use Workouts. Your data lives in iCloud Drive so it's safe and on all your devices.")
|
||||
} actions: {
|
||||
Button("Try Again") { Task { await syncEngine.connect() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
ICloudRequiredView { Task { await syncEngine.connect() } }
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
@@ -31,3 +24,287 @@ struct RootGateView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Brand palette (sampled from the app icon)
|
||||
|
||||
private extension Color {
|
||||
static let brandPurpleLight = Color(red: 0.627, green: 0.302, blue: 0.859) // #A04DDB
|
||||
static let brandPurpleMid = Color(red: 0.478, green: 0.161, blue: 0.776) // #7A29C6
|
||||
static let brandPurpleDark = Color(red: 0.333, green: 0.094, blue: 0.620) // #55189E
|
||||
/// Light teal — bright enough to stay legible on the purple gradient.
|
||||
static let brandTeal = Color(red: 0.176, green: 0.831, blue: 0.749) // #2DD4BF
|
||||
/// Near-black teal ink, for text sitting on a filled teal button/badge.
|
||||
static let brandTealInk = Color(red: 0.043, green: 0.157, blue: 0.145)
|
||||
}
|
||||
|
||||
/// The full-bleed brand background shared by both gate screens.
|
||||
private var brandBackground: some View {
|
||||
LinearGradient(
|
||||
colors: [.brandPurpleLight, .brandPurpleMid, .brandPurpleDark],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
/// Parses inline Markdown and tints the **bold** runs teal, so key terms read
|
||||
/// as highlights against the white body text.
|
||||
private func brandHighlighted(_ markdown: String) -> AttributedString {
|
||||
guard var attributed = try? AttributedString(markdown: markdown) else {
|
||||
return AttributedString(markdown)
|
||||
}
|
||||
let emphasized = attributed.runs
|
||||
.filter { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }
|
||||
.map(\.range)
|
||||
for range in emphasized {
|
||||
attributed[range].foregroundColor = .brandTeal
|
||||
}
|
||||
return attributed
|
||||
}
|
||||
|
||||
// MARK: - Checking
|
||||
|
||||
/// A branded, indeterminate connecting indicator: a rotating teal "comet" arc
|
||||
/// around a steady iCloud glyph, over a faint track ring. Echoes the
|
||||
/// `lock.icloud.fill` badge on the setup screen so the two gate states feel paired.
|
||||
private struct ConnectingIndicator: View {
|
||||
@State private var spin = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.12), lineWidth: 4.5)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.72)
|
||||
.stroke(
|
||||
AngularGradient(
|
||||
colors: [Color.brandTeal.opacity(0), Color.brandTeal],
|
||||
center: .center
|
||||
),
|
||||
style: StrokeStyle(lineWidth: 4.5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(spin ? 360 : 0))
|
||||
|
||||
Image(systemName: "icloud.fill")
|
||||
.font(.system(size: 30, weight: .medium))
|
||||
.foregroundStyle(Color.brandTeal)
|
||||
}
|
||||
.frame(width: 86, height: 86)
|
||||
.onAppear { spin = true }
|
||||
.animation(.linear(duration: 1.1).repeatForever(autoreverses: false), value: spin)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown while we resolve the ubiquity container; branded so the launch into the
|
||||
/// gate (or the app) never flashes a bare system spinner. Resolving the container
|
||||
/// is an opaque system call with no real ETA, so instead of a fake progress bar
|
||||
/// the copy escalates with the wait — a slow first connect (common right after
|
||||
/// enabling iCloud Drive) then reads as steady progress, not a frozen spinner.
|
||||
private struct ICloudCheckingView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
|
||||
@State private var phase: Phase = .connecting
|
||||
@State private var canBail = false
|
||||
|
||||
private enum Phase { case connecting, stillTrying, hint }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
brandBackground
|
||||
VStack(spacing: 16) {
|
||||
ConnectingIndicator()
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.subheadline)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if phase == .hint {
|
||||
Text(brandHighlighted("If you just turned it on, you can check **Settings › your name › iCloud** while you wait."))
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
// Escape hatch: we keep waiting as long as iCloud might still be
|
||||
// coming online, but a user who knows something's off can drop to
|
||||
// the setup gate instead of waiting out the full timeout.
|
||||
if canBail {
|
||||
Button("iCloud not connecting? Set it up") {
|
||||
syncEngine.abandonWaiting()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.brandTeal)
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 10)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 36)
|
||||
.frame(maxWidth: 420)
|
||||
.animation(.easeInOut(duration: 0.4), value: phase)
|
||||
.animation(.easeInOut(duration: 0.4), value: canBail)
|
||||
}
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
phase = .stillTrying
|
||||
try? await Task.sleep(for: .seconds(8)) // 14s in
|
||||
phase = .hint
|
||||
try? await Task.sleep(for: .seconds(14)) // 28s in
|
||||
canBail = true
|
||||
}
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch phase {
|
||||
case .connecting: "Connecting to iCloud…"
|
||||
case .stillTrying, .hint: "Still connecting…"
|
||||
}
|
||||
}
|
||||
|
||||
private var detail: String? {
|
||||
switch phase {
|
||||
case .connecting: nil
|
||||
case .stillTrying, .hint: "This can take a moment right after you turn on iCloud Drive."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iCloud required
|
||||
|
||||
/// Explains — prominently, and explicitly *not* as a login — that Workouts keeps
|
||||
/// the user's data in their own iCloud Drive, and walks them through enabling it.
|
||||
private struct ICloudRequiredView: View {
|
||||
let onCheckAgain: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
private let learnMoreURL = URL(string: "https://indie.rzen.dev/support/icloud-drive")!
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
brandBackground
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
icon
|
||||
headline
|
||||
steps
|
||||
notALoginNote
|
||||
actions
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 40)
|
||||
.frame(maxWidth: 480)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: some View {
|
||||
Image(systemName: "lock.icloud.fill")
|
||||
.font(.system(size: 52, weight: .regular))
|
||||
.foregroundStyle(Color.brandTeal)
|
||||
.frame(width: 112, height: 112)
|
||||
.background(.white.opacity(0.08), in: Circle())
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.14), lineWidth: 1))
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
private var headline: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Your workouts live\nin your iCloud")
|
||||
.font(.system(.largeTitle, design: .rounded, weight: .bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(brandHighlighted("Workouts has no account and no server. Everything you create is saved as files in **your own iCloud Drive** — synced across your devices by Apple, and private to you. Not even the developer can see it."))
|
||||
.font(.callout)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
|
||||
private var steps: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
stepRow(1, "Open **Settings** and tap your name at the top")
|
||||
stepDivider
|
||||
stepRow(2, "Choose **iCloud** and make sure you're signed in")
|
||||
stepDivider
|
||||
stepRow(3, "Turn on **iCloud Drive**")
|
||||
}
|
||||
.background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func stepRow(_ number: Int, _ markdown: String) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Text("\(number)")
|
||||
.font(.system(.subheadline, design: .rounded, weight: .bold))
|
||||
.foregroundStyle(Color.brandTealInk)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(Color.brandTeal, in: Circle())
|
||||
Text(brandHighlighted(markdown))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var stepDivider: some View {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.1))
|
||||
.frame(height: 1)
|
||||
.padding(.leading, 56)
|
||||
}
|
||||
|
||||
private var notALoginNote: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.brandTeal)
|
||||
Text("This isn't an app login. There's no account and no password to create — Workouts simply uses the iCloud already built into your iPhone.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var actions: some View {
|
||||
VStack(spacing: 14) {
|
||||
Button(action: onCheckAgain) {
|
||||
Text("Check Again")
|
||||
.font(.headline)
|
||||
.foregroundStyle(Color.brandTealInk)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.background(Color.brandTeal, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button("Why does Workouts need iCloud?") {
|
||||
openURL(learnMoreURL)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.brandTeal)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user