Add the macOS app: a library-management companion (routines, schedules, exercise browser)
A new 'Workouts Mac' target (same bundle ID as iOS, universal purchase) with a NavigationSplitView shell over the shared data/sync layers: routine management with starter gallery and seed-fork follow, schedule editing (reminders stay iPhone-scheduled), and a browse-only exercise library with the animated figures and reference guides. Multi-writer stance: the Mac writes only routine and schedule documents (whole-document last-writer-wins) and never workout documents, whose per-log merge exists only on the watch ingest path. The Mac reconciles on window activation (throttled) since iCloud syncs while the app is closed, and flushes pending writes on deactivation.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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.
|
||||
/// Mac-native counterpart to `RootGateView` (iOS): same states and brand look,
|
||||
/// with System Settings-flavored copy instead of the Settings app.
|
||||
struct MacRootGateView: View {
|
||||
@Environment(SyncEngine.self) private var syncEngine
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch syncEngine.iCloudStatus {
|
||||
case .checking:
|
||||
MacICloudCheckingView()
|
||||
case .available:
|
||||
MacContentView()
|
||||
case .unavailable:
|
||||
MacICloudRequiredView { 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.
|
||||
private struct MacConnectingIndicator: 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 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.
|
||||
private struct MacICloudCheckingView: 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) {
|
||||
MacConnectingIndicator()
|
||||
.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 **System 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
|
||||
/// via System Settings.
|
||||
private struct MacICloudRequiredView: 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 **System Settings** and click 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 Mac.")
|
||||
.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