Files
workouts/Workouts/Views/RootGateView.swift
T
rzen 208fa73f3d 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
2026-06-21 14:37:30 -04:00

311 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Gates the whole UI on iCloud availability. Files are the source of truth, so
/// there is no meaningful app without iCloud we never fall back to local-only.
struct RootGateView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.scenePhase) private var scenePhase
var body: some View {
Group {
switch syncEngine.iCloudStatus {
case .checking:
ICloudCheckingView()
case .available:
ContentView()
case .unavailable:
ICloudRequiredView { Task { await syncEngine.connect() } }
}
}
.onChange(of: scenePhase) { _, phase in
if phase == .active, syncEngine.iCloudStatus == .unavailable {
Task { await syncEngine.connect() }
}
}
}
}
// 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)
}
}