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) } }