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:
2026-07-16 19:55:06 -04:00
parent f6c5bc911f
commit cbdf02bca7
17 changed files with 2212 additions and 9 deletions
+2
View File
@@ -1,5 +1,7 @@
**July 2026**
Workouts is now on the Mac: a native app for managing routines and schedules and browsing the exercise library, synced through the same iCloud Drive.
Rest time is now set on a wheel with one-second precision — in Settings and in a routine's custom rest — instead of a 5-second stepper.
A live status panel between the timer and the figure now shows heart rate (with the target color cues, in much larger type), HR zone, calories, and workout time.
+7 -5
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftUI (iOS 26 / watchOS 26). It helps users manage workout splits, track exercise logs, and sync across devices. As of **2.0**, persistence is an **iCloud Drive document architecture**: JSON files in iCloud Drive are the sole source of truth, and a rebuildable SwiftData store is a read-through cache. Core Data, `NSPersistentCloudKitContainer`, CloudKit, and the App Group were all removed in 2.0.
A workout tracking app for iPhone, Apple Watch, and Mac, built with Swift 6 and SwiftUI (iOS 26 / watchOS 26 / macOS 26). The Mac app is a library-management companion (routines, schedules, exercise browser — no workout running). It helps users manage workout splits, track exercise logs, and sync across devices. As of **2.0**, persistence is an **iCloud Drive document architecture**: JSON files in iCloud Drive are the sole source of truth, and a rebuildable SwiftData store is a read-through cache. Core Data, `NSPersistentCloudKitContainer`, CloudKit, and the App Group were all removed in 2.0.
## Development Commands
@@ -24,7 +24,7 @@ A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftU
### Persistence & Sync (iCloud Drive documents)
- **Source of truth**: JSON documents in the iCloud Drive container `iCloud.dev.rzen.indie.Workouts`. One file per aggregate: `Splits/<ULID>.json` and `Workouts/YYYY/MM/<ULID>.json` (month-bucketed; ULIDs sort chronologically).
- **SwiftData cache**: `WorkoutsModelContainer` (`Shared/Persistence/`) builds a local SwiftData store as a *rebuildable read-through cache*, created with `cloudKitDatabase: .none`. It is wiped on a schema-version bump or an iCloud account change. Views read it via `@Query`.
- **One-way data flow (iPhone)**: view → `SyncEngine.save(...)` writes a file → `MetadataObserver` (an `NSMetadataQuery`) emits a delta batch → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive.
- **One-way data flow (iPhone & Mac)**: view → `SyncEngine.save(...)` writes a file → `MetadataObserver` (an `NSMetadataQuery`) emits a delta batch → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone and the Mac both write iCloud Drive; the Mac writes **only routine and schedule documents** (whole-document last-writer-wins — it must never write workout documents, whose per-log merge exists only on the watch ingest path).
- **Key types**:
- `SyncEngine` (`@Observable @MainActor`, in `Workouts/Sync/`) — the app-specific orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(_:)` applies observer delta batches; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback.
- The file-side core comes from the **IndieSync** SPM package (`https://git.rzen.dev/rzen/indie-sync.git`): `DocumentFileStore` (actor — all `NSFileCoordinator` I/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration), `TombstoneStore` (soft-delete stubs, resurrection veto, grace-period pruning), `MetadataObserver` (`@MainActor` — wraps the `NSMetadataQuery`, emitting added/modified/removed batches as an `AsyncStream`), plus `ULID`, `DocumentCoder`, `SyncError`, and the `VersionedDocument` protocol. See the `icloud-sync-engine` skill for the architecture invariants.
@@ -46,25 +46,27 @@ All in `Shared/Model/`:
### App Structure & DI
- **iOS**: `WorkoutsApp` (`@main`) owns `AppServices` (`@Observable @MainActor`: `container`, `syncEngine`, `watchBridge`, `workoutLauncher`), injects them via `.environment(...)` / `.modelContainer(...)`, then runs `await services.bootstrap()` (connect + activate). Root is `RootGateView``ContentView``WorkoutLogsView` (a single screen — there is no TabView).
- **watchOS**: `WorkoutsWatchApp` (`@main`) owns `WatchAppServices` (`container` + `bridge`; no iCloud, no SyncEngine) and a `WatchAppDelegate`. Root is `ContentView``ActiveWorkoutGateView`.
- **macOS**: `WorkoutsMacApp` (`@main`, in `Workouts Mac/`) owns `MacAppServices` (`container` + `syncEngine` only — no watch bridge, HealthKit, Live Activity, or backup). Root is `MacRootGateView``MacContentView` (a `NavigationSplitView`: routines sidebar, schedules, exercise browser). Reconciles on window activation (throttled) because iCloud can sync while the app isn't running; flushes pending writes on deactivation.
- Views read services with `@Environment(SyncEngine.self)` (etc.), read data with `@Query`, and write with `await sync.save(...)` / `delete(...)`.
### Watch Sync (WatchConnectivity bridge)
- iPhone is the sole writer of iCloud Drive; the watch is a thin remote that round-trips domain documents through the phone, keyed by stable ULIDs.
- The watch never touches iCloud Drive; it is a thin remote that round-trips domain documents through the phone, keyed by stable ULIDs.
- **Wire format** `Shared/Connectivity/WCPayload.swift`. Phone→Watch: full state (splits + recent workouts) plus settings (rest seconds, done-countdown seconds) via `updateApplicationContext` (latest-wins). Watch→Phone: `workoutUpdate` (one `WorkoutDocument`) and `requestSync`.
- **Phone** `Workouts/Connectivity/PhoneConnectivityBridge.swift` — pushes on `onCacheChanged`; inbound updates go to `SyncEngine.ingestFromWatch`.
- **Watch** `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift` — a local SwiftData cache fed only by phone pushes; applies updates optimistically, then forwards.
- **Watch HealthKit session** (runtime only, *not* sync): `WorkoutSessionManager` holds an `HKWorkoutSession` so the watch stays foregrounded during a workout; the phone launches it via `Workouts/HealthKit/WorkoutLauncher.swift`.
### Platforms, Tooling & Entitlements
- iOS 26 / watchOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on both targets.
- iOS 26 / watchOS 26 / macOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on all targets.
- Build number (git commit count), `BuildDate`, and `BuildHash` are stamped by the `app-versioning` skill's `update_build_info.sh` (referenced in place from `../indie-skills`, a post-build phase; release tagging is opt-in via `RELEASE_TAGGING=1`). `Scripts/` holds the App Store / TestFlight release pipeline.
- Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only. **No CloudKit service, no App Group.**
- Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only; macOS uses app sandbox + CloudDocuments (same bundle ID as iOS — universal purchase). **No CloudKit service, no App Group.**
- SPM packages: `IndieSync` (file-side iCloud sync core) and `IndieAbout` (in-app About / changelog / license UI).
### Key Directories
- `Shared/` (compiled into both targets): `Model/`, `Persistence/`, `Connectivity/`, `Utils/`, `Screenshots/`
- `Workouts/` (iOS): `Sync/` (the persistence/sync layer), `Connectivity/`, `HealthKit/`, `Seed/`, `Views/` (`Common/`, `Exercises/`, `Settings/`, `Splits/`, `WorkoutLogs/`), `Resources/` (Info.plist, entitlements, `StarterSplits/`, `ExerciseMotions/`)
- `Workouts Watch App/` (watchOS): `Connectivity/`, `Views/`, plus `WorkoutSessionManager`, `WatchAppDelegate`, `WatchAppServices`
- `Workouts Mac/` (macOS): `WorkoutsMacApp`, `MacAppServices`, `Views/` (`Routines/`, `Schedules/`, `Exercises/`), `Resources/`; also compiles `Workouts/Sync/`, `Workouts/Seed/`, `Workouts/ExerciseFigure/`, and a few portable files from `Workouts/Views/` (see `project.yml`)
- Root: `project.yml`, `Scripts/`, and `CHANGELOG.md` / `README.md` / `LICENSE.md` (bundled into the iOS app for IndieAbout)
### Starter Data (deterministic seeds)
+11 -4
View File
@@ -1,6 +1,6 @@
# Workouts
A workout tracking app for iPhone and Apple Watch. Build workout splits, run
A workout tracking app for iPhone, Apple Watch, and Mac. Build workout splits, run
sessions, and track your progress — with your data stored as plain JSON files in
your own iCloud Drive.
@@ -110,6 +110,12 @@ your own iCloud Drive.
(duration, calories, avg/max heart rate, heart-rate zones with a legend, total
volume) you can revisit on any past workout. Weights display in your choice of
pounds or kilograms.
- **Mac app** — a native macOS companion for managing your library: create, edit,
reorder, and duplicate routines in a sidebar-driven window, manage schedules
(recurrence, weekdays, reminder times — your iPhone sends the notifications),
browse the starter gallery, and explore the exercise library with the same
animated figures and reference guides. Library management only — workouts still
run on iPhone and Apple Watch. Same iCloud data, no extra setup.
- **iCloud Drive sync** — your data lives as human-readable JSON in your iCloud
Drive, synced across devices and visible in the Files app. iCloud is required.
- **Backups** — create point-in-time snapshots of all your data from Settings,
@@ -123,8 +129,9 @@ your own iCloud Drive.
iCloud Drive JSON documents are the **sole source of truth**; a local SwiftData
store is a rebuildable read-through cache populated exclusively by an
`NSMetadataQuery` observer (one-way flow: files → observer → cache). The phone is
the only device that touches iCloud Drive; the Apple Watch is a thin remote that
`NSMetadataQuery` observer (one-way flow: files → observer → cache). The phone
and the Mac write iCloud Drive directly (the Mac only routines and schedules —
whole-document last-writer-wins); the Apple Watch is a thin remote that
round-trips workout changes through the phone via WatchConnectivity.
See `REQUIREMENTS.md` for the data model and `CLAUDE.md` for project guidance.
@@ -138,4 +145,4 @@ xcodegen generate
open Workouts.xcodeproj
```
Requires Xcode 26 (iOS 26 / watchOS 26, Swift 6).
Requires Xcode 26 (iOS 26 / watchOS 26 / macOS 26, Swift 6).
+35
View File
@@ -0,0 +1,35 @@
import Foundation
import Observation
import SwiftData
/// Composition root for the Mac app. Owns the SwiftData cache container and the
/// iCloud sync engine only no watch bridge, HealthKit, ActivityKit, backup, or
/// reminders/speech. The Mac's MVP is browsing and managing the library
/// (routines/schedules/exercises); it never runs a workout. Injected into the
/// view tree via `.environment(...)`.
@Observable
@MainActor
final class MacAppServices {
let container: ModelContainer
let syncEngine: SyncEngine
private var bootstrapTask: Task<Void, Never>?
init() {
let container = WorkoutsModelContainer.make()
self.container = container
self.syncEngine = SyncEngine(container: container)
}
/// Launch step: resolve iCloud and reconcile the cache. Idempotent repeated
/// callers await the same one-shot task.
func bootstrap() async {
if let bootstrapTask { await bootstrapTask.value; return }
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.syncEngine.connect()
}
bootstrapTask = task
await task.value
}
}
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Workouts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.healthcare-fitness</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.dev.rzen.indie.Workouts</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerName</key>
<string>Workouts</string>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Workouts</string>
</array>
</dict>
</plist>
@@ -0,0 +1,123 @@
import SwiftUI
/// The bundled exercise-library browser: a Mac-native two-pane layout over the same
/// `ExerciseCatalog` sections and search the iOS Library tab uses
/// (`Workouts/Views/Exercises/ExerciseLibraryView.swift`), but browse-only no
/// machine-settings editor, no weight-progression chart, no workout history. The left
/// pane is a searchable, section-grouped list with a single selection; the right pane
/// shows the selected exercise's animated figure and reference info, the same
/// components/entry points iOS uses (`ExerciseFigureSlot`, `ExerciseInfoContent`).
struct MacExercisesBrowserView: View {
@State private var searchText = ""
@State private var selection: String?
private var sections: [ExerciseCatalogSection] {
ExerciseCatalog.sections(matching: searchText)
}
var body: some View {
HSplitView {
list
.frame(minWidth: 220, idealWidth: 260, maxWidth: 340)
detail
.frame(minWidth: 420, maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Exercises")
.searchable(text: $searchText, prompt: "Name, category, or muscle")
}
// MARK: - List
@ViewBuilder
private var list: some View {
if sections.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(selection: $selection) {
ForEach(sections, id: \.title) { section in
Section(section.title) {
ForEach(section.names, id: \.self) { name in
Text(name)
.tag(name)
}
}
}
}
}
}
// MARK: - Detail
@ViewBuilder
private var detail: some View {
if let selection {
MacExerciseDetailView(exerciseName: selection)
// Reseed the detail's own state when the selection changes.
.id(selection)
} else {
ContentUnavailableView(
"Select an Exercise",
systemImage: "figure.strengthtraining.traditional",
description: Text("Choose an exercise from the list to see how it's performed.")
)
}
}
}
/// One library exercise, browse-only: the animated form-guide figure (when a bundled
/// motion rig matches the name) over the authored reference copy (when a bundled
/// `info.md` matches). Either or both can be absent for a given exercise, so each
/// half degrades gracefully instead of leaving blank space, mirroring how the iOS
/// detail screen simply omits what it doesn't have (`ExerciseLibraryDetailView`).
private struct MacExerciseDetailView: View {
let exerciseName: String
/// Cheap presence check a name-set lookup against the bundle enumeration
/// `ExerciseMotionLibrary.exerciseNames` already did once, rather than
/// `resources(for:)`'s two bundle reads + two JSON decodes just to answer a Bool.
private var hasFigure: Bool {
ExerciseMotionLibrary.exerciseNames.contains(exerciseName)
}
var body: some View {
let info = ExerciseInfoLibrary.info(for: exerciseName)
let hasFigure = hasFigure
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if hasFigure || info == nil {
figureArea(hasFigure: hasFigure)
}
if let info {
ExerciseInfoContent(info: info)
} else if hasFigure {
Text("No reference guide written for this exercise yet.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle(exerciseName)
}
/// The figure, sized generously for the Mac detail pane, or when neither a
/// motion rig nor reference info exists for this name a single combined empty
/// state rather than two stacked placeholders.
@ViewBuilder
private func figureArea(hasFigure: Bool) -> some View {
if hasFigure {
ExerciseFigureSlot(exerciseName: exerciseName)
.frame(maxWidth: .infinity)
.frame(height: 260)
} else {
ContentUnavailableView(
exerciseName,
systemImage: "figure.strengthtraining.traditional",
description: Text("No animated guide or reference info is available for this exercise yet.")
)
.frame(maxWidth: .infinity)
}
}
}
+246
View File
@@ -0,0 +1,246 @@
import SwiftUI
import SwiftData
/// Which sidebar item is selected. `routine` carries the routine's ULID; the two
/// Library items are singletons. Starter Gallery and New Routine are actions
/// (sheets), not selectable destinations, so they're deliberately absent here.
enum SidebarSelection: Hashable {
case routine(String) // ULID
case schedules
case exercises
}
/// A routine flagged for deletion captured as plain values (id + name), not a
/// retained `@Model`. Reading a persisted property on a since-deleted entity traps
/// (the repo's known crash class), so the confirmation dialog reads only these
/// captured strings and re-resolves the live routine by id at delete time.
private struct PendingRoutineDelete: Identifiable {
let id: String
let name: String
}
/// A routine flagged for editing captured as just its id, not a retained `@Model`
/// (see `PendingRoutineDelete`). The sheet re-resolves the live routine by id when it
/// presents, so a remote delete between the tap and the sheet's next body evaluation
/// can't hand the editor a dangling entity.
private struct PendingRoutineEdit: Identifiable {
let id: String
}
/// Root content once iCloud is available: a Mac-native `NavigationSplitView` shell.
/// The sidebar lists the two Library destinations plus every routine (reorderable,
/// with a per-row context menu); the detail column swaps on selection. New Routine
/// and the Starter Gallery are surfaced as sheets from the sidebar toolbar.
struct MacContentView: View {
@Environment(SyncEngine.self) private var syncEngine
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@Query private var schedules: [Schedule]
@State private var selection: SidebarSelection?
@State private var showingNewRoutine = false
@State private var showingStarterGallery = false
@State private var routineToEdit: PendingRoutineEdit?
@State private var pendingDelete: PendingRoutineDelete?
var body: some View {
NavigationSplitView {
sidebar
} detail: {
detail
}
.sheet(isPresented: $showingNewRoutine) {
MacRoutineEditorSheet(routine: nil)
}
.sheet(isPresented: $showingStarterGallery) {
MacStarterGalleryView()
}
.sheet(item: $routineToEdit) { pending in
if let routine = routines.first(where: { $0.id == pending.id }), routine.isLive {
MacRoutineEditorSheet(routine: routine)
}
}
.confirmationDialog(
"Delete Routine?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
titleVisibility: .visible,
presenting: pendingDelete
) { pending in
Button("Delete", role: .destructive) { confirmDelete(pending) }
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: { pending in
Text(deleteMessage(for: pending))
}
// Follow a seed fork: a clone-on-edit swaps the selected routine's id, so
// keep the sidebar highlight pointed at the live clone. The detail column
// follows on its own (it re-resolves the id every render).
.onChange(of: syncEngine.cloneRedirects) {
guard case let .routine(id) = selection else { return }
let resolved = syncEngine.currentRoutineID(for: id)
if resolved != id { selection = .routine(resolved) }
}
}
// MARK: - Sidebar
private var sidebar: some View {
List(selection: $selection) {
Section("Library") {
Label("Schedules", systemImage: "calendar")
.tag(SidebarSelection.schedules)
Label("Exercises", systemImage: "figure.strengthtraining.traditional")
.tag(SidebarSelection.exercises)
}
Section("Routines") {
ForEach(routines) { routine in
RoutineSidebarRow(routine: routine)
.tag(SidebarSelection.routine(routine.id))
.contextMenu {
Button("Edit…") { routineToEdit = PendingRoutineEdit(id: routine.id) }
Button("Duplicate") {
Task { await syncEngine.duplicate(routine: routine) }
}
Divider()
Button("Delete", role: .destructive) {
pendingDelete = PendingRoutineDelete(id: routine.id, name: routine.name)
}
}
}
.onMove(perform: moveRoutines)
if routines.isEmpty {
Text("No routines yet.")
.foregroundStyle(.secondary)
.font(.callout)
}
}
}
.navigationTitle("Workouts")
.toolbar {
ToolbarItemGroup {
Button {
showingNewRoutine = true
} label: {
Label("New Routine", systemImage: "plus")
}
.help("New Routine")
Button {
showingStarterGallery = true
} label: {
Label("Starter Gallery", systemImage: "sparkles")
}
.help("Browse Starter Routines")
}
}
}
// MARK: - Detail
@ViewBuilder
private var detail: some View {
switch selection {
case .routine(let id):
// Reading `currentRoutineID` here establishes the observation dependency
// on `cloneRedirects`, so a fork re-renders this branch and the `.id`
// reseeds the detail's own state onto the clone.
let resolved = syncEngine.currentRoutineID(for: id)
MacRoutineDetailView(routineID: resolved)
.id(resolved)
case .schedules:
MacSchedulesView()
case .exercises:
MacExercisesBrowserView()
case nil:
ContentUnavailableView(
"Your Library",
systemImage: "dumbbell",
description: Text("Select a routine, or pick Schedules or Exercises from the sidebar.")
)
}
}
// MARK: - Actions
/// Reorder and renumber via `RoutineOrdering`, writing only the routines whose
/// order actually changed. Order-only writes deliberately leave `updatedAt`
/// alone touching it would fork a starter (its `order` is normalized out of the
/// pristine check, but `updatedAt` is stamped on every real edit). Mirrors iOS
/// `RoutinesLibraryView.moveRoutines`.
private func moveRoutines(from source: IndexSet, to destination: Int) {
let ids = routines.map(\.id)
let currentOrders = Dictionary(uniqueKeysWithValues: routines.map { ($0.id, $0.order) })
let changes = RoutineOrdering.changedOrders(
ids: ids, currentOrders: currentOrders, from: source, to: destination)
guard !changes.isEmpty else { return }
Task {
for change in changes {
guard let routine = routines.first(where: { $0.id == change.id }), routine.isLive else { continue }
var doc = RoutineDocument(from: routine)
doc.order = change.order
await syncEngine.save(routine: doc)
}
}
}
private func confirmDelete(_ pending: PendingRoutineDelete) {
if let routine = routines.first(where: { $0.id == pending.id }), routine.isLive {
Task { await syncEngine.delete(routine: routine) }
}
if case .routine(pending.id) = selection { selection = nil }
pendingDelete = nil
}
/// Delete-confirmation body, extended with a note about any schedules that point
/// at this routine deleting the routine leaves them behind. Mirrors iOS.
private func deleteMessage(for pending: PendingRoutineDelete) -> String {
let base = "This will permanently delete \"\(pending.name)\" and all its exercises. Past workouts are kept."
let count = schedules.filter { syncEngine.currentRoutineID(for: $0.routineID) == pending.id }.count
guard count > 0 else { return base }
return base + " Its \(count) scheduled day\(count == 1 ? "" : "s") will stay on the schedule without a startable routine."
}
}
/// A single routine row in the sidebar: a rounded-square color badge with the
/// routine's symbol, its name (with a "Starter" capsule for a bundled seed), and an
/// exercise-count caption. Mirrors iOS `RoutineRow`, sans the last-trained line
/// (Mac doesn't run workouts).
private struct RoutineSidebarRow: View {
let routine: Routine
var body: some View {
HStack(spacing: 10) {
Image(systemName: routine.systemImage)
.font(.body)
.foregroundStyle(routineColor)
.frame(width: 28, height: 28)
.background(routineColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text(routine.name)
if SeedLibrary.isSeed(id: routine.id) {
Text("Starter")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.secondary.opacity(0.15), in: Capsule())
}
}
Text("\(routine.exercisesArray.count) exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var routineColor: Color { Color.color(from: routine.color) }
}
+305
View File
@@ -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)
}
}
@@ -0,0 +1,297 @@
import IndieSync
import SwiftUI
import SwiftData
/// Add or edit one exercise in a routine. Add mode picks a name from the searchable,
/// grouped bundled catalog (`ExerciseCatalog.sections`) and seeds its authored
/// defaults; edit mode pre-fills from the existing exercise. Every save rebuilds the
/// parent `RoutineDocument` and goes through `syncEngine.save(routine:)`. Uses only
/// macOS-safe controls Steppers/TextField/menu Pickers, never `.pickerStyle(.wheel)`
/// or `.textInputAutocapitalization`.
struct MacExerciseEditorSheet: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.dismiss) private var dismiss
let routineID: String
/// nil in add mode. Captured as a value in `init` so save never reads a retained
/// (possibly since-deleted) `@Model`.
private let exerciseID: String?
@Query private var routines: [Routine]
@State private var name: String
/// The library exercise this row derives from carried through a rename so the
/// figure/info link survives. Mirrors `ExerciseDocument.libraryName`.
@State private var libraryName: String?
@State private var loadType: LoadType
@State private var sets: Int
@State private var reps: Int
@State private var weight: Double
@State private var minutes: Int
@State private var seconds: Int
@State private var machineEnabled: Bool
@State private var machineSettings: [MachineSetting]
@State private var showingCatalogPicker = false
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
var isAdding: Bool { exerciseID == nil }
init(routineID: String, exercise: Exercise?) {
self.routineID = routineID
self.exerciseID = exercise?.id
_name = State(initialValue: exercise?.name ?? "")
_libraryName = State(initialValue: exercise.flatMap { ex in
ex.libraryName ?? (ExerciseMotionLibrary.exerciseNames.contains(ex.name) ? ex.name : nil)
})
_loadType = State(initialValue: exercise?.loadTypeEnum ?? .weight)
_sets = State(initialValue: exercise?.sets ?? 3)
_reps = State(initialValue: exercise?.reps ?? 10)
_weight = State(initialValue: exercise?.weight ?? 0)
_minutes = State(initialValue: exercise?.durationMinutes ?? 0)
_seconds = State(initialValue: exercise?.durationSeconds ?? 0)
_machineEnabled = State(initialValue: exercise?.machineSettings != nil)
_machineSettings = State(initialValue: exercise?.machineSettings ?? [])
}
private var routine: Routine? {
let id = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
NavigationStack {
editorForm
}
.frame(width: 460, height: 560)
.sheet(isPresented: $showingCatalogPicker) {
MacExerciseCatalogPicker { picked in
pick(picked)
}
}
}
private var editorForm: some View {
Form {
Section("Exercise") {
if name.isEmpty {
Button("Select Exercise…") { showingCatalogPicker = true }
} else {
if let libraryName, libraryName != name {
LabeledContent("From", value: libraryName)
}
TextField("Name", text: $name)
Button("Choose a Different Exercise…") { showingCatalogPicker = true }
.font(.caption)
}
}
Section("Sets & Reps") {
Stepper(value: $sets, in: 0...20) { LabeledContent("Sets", value: "\(sets)") }
Stepper(value: $reps, in: 0...100) { LabeledContent("Reps", value: "\(reps)") }
}
Section("Load Type") {
Picker("Load Type", selection: $loadType) {
ForEach(LoadType.allCases, id: \.self) { load in
Text(load.name).tag(load)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
if loadType == .weight {
Section("Weight") {
HStack(spacing: 8) {
TextField("Weight", value: $weight, format: .number)
.frame(width: 70)
.multilineTextAlignment(.trailing)
.textFieldStyle(.roundedBorder)
Text(weightUnit.abbreviation).foregroundStyle(.secondary)
Spacer()
Stepper("Weight", value: $weight, in: 0...2000, step: 5).labelsHidden()
}
}
}
if loadType == .duration {
Section("Duration") {
Stepper(value: $minutes, in: 0...59) { LabeledContent("Minutes", value: "\(minutes)") }
Stepper(value: $seconds, in: 0...59) { LabeledContent("Seconds", value: "\(seconds)") }
}
}
Section {
Toggle("Machine-based", isOn: $machineEnabled.animation())
if machineEnabled {
MacMachineSettingsEditor(settings: $machineSettings)
}
} header: {
Text("Machine")
} footer: {
Text("Turn on for machine-based exercises to record comfort settings (seat height, back-rest incline, pin position…).")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.navigationTitle(isAdding ? "Add Exercise" : (name.isEmpty ? "Edit Exercise" : name))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save(); dismiss() }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
/// Apply a picked library exercise. Add mode seeds the library's authored defaults
/// (sets/reps/load/duration); edit mode only relinks the name (parity with iOS,
/// which never resets an existing exercise's plan on a re-pick).
private func pick(_ exerciseName: String) {
name = exerciseName
libraryName = exerciseName
guard isAdding, let defaults = ExerciseInfoLibrary.info(for: exerciseName)?.defaults else { return }
sets = defaults.sets
reps = defaults.reps
loadType = defaults.loadType
minutes = defaults.durationSeconds / 60
seconds = defaults.durationSeconds % 60
}
private func save() {
guard let routine else { return }
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let finalName = trimmed.isEmpty ? (libraryName ?? "") : trimmed
guard !finalName.isEmpty else { return }
// nil when the name matches the library exercise (unrenamed) keeps an
// unrenamed exercise byte-identical on disk.
let storedLibraryName = (libraryName == finalName) ? nil : libraryName
let durationSecs = minutes * 60 + seconds
var doc = RoutineDocument(from: routine)
if let exerciseID, let idx = doc.exercises.firstIndex(where: { $0.id == exerciseID }) {
doc.exercises[idx].name = finalName
doc.exercises[idx].libraryName = storedLibraryName
doc.exercises[idx].sets = sets
doc.exercises[idx].reps = reps
doc.exercises[idx].weight = weight
doc.exercises[idx].loadType = loadType.rawValue
doc.exercises[idx].durationSeconds = durationSecs
doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
} else {
let ed = ExerciseDocument(
id: ULID.make(),
name: finalName,
order: doc.exercises.count,
sets: sets,
reps: reps,
weight: weight,
loadType: loadType.rawValue,
durationSeconds: durationSecs,
machineSettings: machineEnabled ? machineSettings : nil,
libraryName: storedLibraryName
)
doc.exercises.append(ed)
}
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
}
/// A searchable, catalog-grouped exercise-name picker (`ExerciseCatalog.sections`),
/// single-select. Returns the chosen name to `onSelect` and dismisses.
private struct MacExerciseCatalogPicker: View {
@Environment(\.dismiss) private var dismiss
let onSelect: (String) -> Void
@State private var query = ""
var body: some View {
NavigationStack {
List {
ForEach(ExerciseCatalog.sections(matching: query), id: \.title) { section in
Section(section.title) {
ForEach(section.names, id: \.self) { name in
Button {
onSelect(name)
dismiss()
} label: {
Text(name).frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
}
}
}
.searchable(text: $query, prompt: "Search exercises")
.navigationTitle("Choose Exercise")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.frame(width: 440, height: 520)
}
}
/// macOS-safe editor for an ordered list of machine comfort settings name/value
/// TextField rows with an explicit remove button and an add row. Mirrors the iOS
/// `MachineSettingsEditor` semantics without `.textInputAutocapitalization` (which
/// doesn't exist on macOS). Reorder is dropped (minor on Mac); add/edit/delete stay.
private struct MacMachineSettingsEditor: View {
@Binding var settings: [MachineSetting]
@State private var rows: [Row]
init(settings: Binding<[MachineSetting]>) {
_settings = settings
_rows = State(initialValue: settings.wrappedValue.map(Row.init))
}
var body: some View {
ForEach($rows) { $row in
HStack {
TextField("Setting", text: $row.name)
TextField("Value", text: $row.value)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.frame(maxWidth: 120)
Button {
rows.removeAll { $0.id == row.id }
} label: {
Image(systemName: "minus.circle.fill").foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
Button {
rows.append(Row(MachineSetting(name: "", value: "")))
} label: {
Label("Add Setting", systemImage: "plus.circle.fill")
}
.onChange(of: rows) { _, newRows in
settings = newRows.map(\.setting)
}
}
/// Session-only identified wrapper so `ForEach` has a stable identity that
/// survives text edits.
fileprivate struct Row: Identifiable, Equatable {
let id = UUID()
var name: String
var value: String
init(_ setting: MachineSetting) {
name = setting.name
value = setting.value
}
var setting: MachineSetting { MachineSetting(name: name, value: value) }
}
}
@@ -0,0 +1,219 @@
import IndieSync
import SwiftUI
import SwiftData
/// An exercise flagged for deletion captured as plain values, never a retained
/// `@Model` (see `PendingRoutineDelete`). The routine document is rebuilt by
/// exercise id, so the id is all the delete needs; the name is only for the prompt.
private struct PendingExerciseDelete: Identifiable {
let id: String
let name: String
}
/// An exercise flagged for editing captured as just its id, not a retained `@Model`
/// (see `PendingExerciseDelete`). The sheet re-resolves the live exercise by id off the
/// current `routine` when it presents, so a remote delete between the tap and the
/// sheet's next body evaluation can't hand the editor a dangling entity.
private struct PendingExerciseEdit: Identifiable {
let id: String
}
/// The detail pane for one routine: a header (color badge, name, activity type, Edit)
/// over a reorderable exercise list. All mutations rebuild the `RoutineDocument` and go
/// through `syncEngine.save(routine:)` never the SwiftData cache directly so editing
/// a starter forks it to a fresh ULID for free. The routine is resolved by id every
/// render (never a captured entity) so an open detail follows that fork, mirroring iOS
/// `RoutineDetailView`.
struct MacRoutineDetailView: View {
@Environment(SyncEngine.self) private var syncEngine
@State private var routineID: String
@Query private var routines: [Routine]
@State private var showingRoutineEdit = false
@State private var showingAddExercise = false
@State private var exerciseToEdit: PendingExerciseEdit?
@State private var pendingExerciseDelete: PendingExerciseDelete?
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(routineID: String) {
_routineID = State(initialValue: routineID)
}
/// The live routine behind the id we hold, resolved through the seed clone-on-edit
/// redirect so a fork mid-screen keeps resolving.
private var routine: Routine? {
let id = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
Group {
if let routine {
content(for: routine)
} else {
// The id no longer maps to a live routine (deleted elsewhere, or a
// transient mid-clone frame).
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
}
}
.sheet(isPresented: $showingRoutineEdit) {
if let routine { MacRoutineEditorSheet(routine: routine) }
}
.sheet(isPresented: $showingAddExercise) {
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: nil)
}
.sheet(item: $exerciseToEdit) { pending in
if let exercise = routine?.exercisesArray.first(where: { $0.id == pending.id }), exercise.isLive {
MacExerciseEditorSheet(routineID: syncEngine.currentRoutineID(for: routineID), exercise: exercise)
}
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding(
get: { pendingExerciseDelete != nil },
set: { if !$0 { pendingExerciseDelete = nil } }
),
titleVisibility: .visible,
presenting: pendingExerciseDelete
) { pending in
Button("Delete", role: .destructive) {
deleteExercise(id: pending.id)
pendingExerciseDelete = nil
}
Button("Cancel", role: .cancel) { pendingExerciseDelete = nil }
} message: { pending in
Text("Remove \"\(pending.name)\" from this routine?")
}
}
@ViewBuilder
private func content(for routine: Routine) -> some View {
List {
Section {
header(for: routine)
}
Section("Exercises") {
if routine.exercisesArray.isEmpty {
Text("No exercises added yet.")
.foregroundStyle(.secondary)
} else {
ForEach(routine.exercisesArray) { exercise in
ExerciseRow(exercise: exercise, weightUnit: weightUnit)
.contentShape(Rectangle())
.onTapGesture(count: 2) { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
.contextMenu {
Button("Edit…") { exerciseToEdit = PendingExerciseEdit(id: exercise.id) }
Button("Duplicate") { duplicateExercise(id: exercise.id) }
Divider()
Button("Delete", role: .destructive) {
pendingExerciseDelete = PendingExerciseDelete(id: exercise.id, name: exercise.name)
}
}
}
.onMove { source, destination in
moveExercises(routine: routine, from: source, to: destination)
}
}
}
}
.navigationTitle(routine.name)
.toolbar {
ToolbarItem {
Button {
showingAddExercise = true
} label: {
Label("Add Exercise", systemImage: "plus")
}
}
}
}
private func header(for routine: Routine) -> some View {
HStack(spacing: 14) {
Image(systemName: routine.systemImage)
.font(.largeTitle)
.foregroundStyle(Color.color(from: routine.color))
.frame(width: 56, height: 56)
.background(Color.color(from: routine.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 4) {
Text(routine.name)
.font(.title2.weight(.semibold))
Label(routine.activityTypeEnum.displayName, systemImage: routine.activityTypeEnum.systemImage)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button {
showingRoutineEdit = true
} label: {
Label("Edit", systemImage: "pencil")
}
}
.padding(.vertical, 6)
}
// MARK: - Mutations (all rebuild the document, never the cache)
/// Reorder and renumber the exercises, then save. Resolves the routine at call time
/// so it follows a clone-on-edit. Stamps `updatedAt` an exercise reorder is a real
/// content edit that should fork a starter (unlike a routine-list reorder).
private func moveExercises(routine: Routine, from source: IndexSet, to destination: Int) {
var ordered = routine.exercisesArray
ordered.move(fromOffsets: source, toOffset: destination)
var doc = RoutineDocument(from: routine)
doc.exercises = ordered.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
private func deleteExercise(id: String) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
doc.exercises.removeAll { $0.id == id }
for i in doc.exercises.indices { doc.exercises[i].order = i }
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
/// Copy an exercise in place, right after itself (the interval-segment case). The
/// clone is an exact duplicate under a fresh id; renaming it is a follow-up edit.
/// Mirrors iOS `RoutineDetailView.duplicateExercise`.
private func duplicateExercise(id: String) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
guard let idx = doc.exercises.firstIndex(where: { $0.id == id }) else { return }
var copy = doc.exercises[idx]
copy.id = ULID.make()
doc.exercises.insert(copy, at: idx + 1)
for i in doc.exercises.indices { doc.exercises[i].order = i }
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
}
}
/// One exercise row: name over a one-line plan summary (`ExerciseDocument+PlanSummary`
/// parity via `Exercise.planSummary`).
private struct ExerciseRow: View {
let exercise: Exercise
let weightUnit: WeightUnit
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(exercise.name)
Text(exercise.planSummary(weightUnit: weightUnit))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 2)
}
}
@@ -0,0 +1,181 @@
import IndieSync
import SwiftUI
import SwiftData
/// Create or edit a routine's identity: name, icon, color, and activity type. Mac-
/// native sheet (grouped Form, fixed width, Cancel/Save). The workout-run settings
/// the iOS editor also carries (rest, auto-advance, target heart rate) are omitted
/// the Mac never runs a workout but any values already set on the routine are
/// preserved on save, since we rebuild from `RoutineDocument(from:)` and touch only
/// these four fields.
struct MacRoutineEditorSheet: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
// Resolve by id, not a captured entity: a clone-on-edit swaps a seed's identity
// mid-edit. `routineID` is nil in create mode. Mirrors iOS `RoutineAddEditView`.
@State private var routineID: String?
@Query private var routines: [Routine]
@State private var name: String
@State private var color: String
@State private var systemImage: String
@State private var activityType: WorkoutActivityType
var isEditing: Bool { routineID != nil }
init(routine: Routine?) {
_routineID = State(initialValue: routine?.id)
_name = State(initialValue: routine?.name ?? "")
_color = State(initialValue: routine?.color ?? "indigo")
_systemImage = State(initialValue: routine?.systemImage ?? "dumbbell.fill")
_activityType = State(initialValue: routine?.activityTypeEnum ?? .traditionalStrength)
}
private var routine: Routine? {
guard let routineID else { return nil }
let id = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Name", text: $name)
if let routineID, SeedLibrary.isSeed(id: routineID) {
Text("Saving changes to a starter routine creates your own copy; the starter stays available in the gallery.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Icon") {
IconGridPicker(selection: $systemImage, tint: Color.color(from: color))
}
Section("Color") {
ColorSwatchPicker(selection: $color)
}
Section {
Picker("Activity Type", selection: $activityType) {
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
Label(type.displayName, systemImage: type.systemImage).tag(type)
}
}
} header: {
Text("Activity Type")
} footer: {
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.navigationTitle(isEditing ? "Edit Routine" : "New Routine")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save(); dismiss() }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
.frame(width: 460, height: 520)
}
private func save() {
if isEditing {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
doc.name = name
doc.color = color
doc.systemImage = systemImage
doc.activityType = activityType.rawValue
doc.updatedAt = Date()
Task { await syncEngine.save(routine: doc) }
} else {
let existing = (try? modelContext.fetch(FetchDescriptor<Routine>())) ?? []
let now = Date()
let doc = RoutineDocument(
schemaVersion: RoutineDocument.currentSchemaVersion,
id: ULID.make(),
name: name,
color: color,
systemImage: systemImage,
order: existing.count,
createdAt: now,
updatedAt: now,
exercises: [],
activityType: activityType.rawValue
)
Task { await syncEngine.save(routine: doc) }
}
}
}
/// A grid of the curated `availableIcons`, single-select, with the chosen one ringed.
private struct IconGridPicker: View {
@Binding var selection: String
let tint: Color
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 8)]
var body: some View {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(availableIcons, id: \.self) { icon in
Button {
selection = icon
} label: {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(icon == selection ? tint : .secondary)
.frame(width: 40, height: 40)
.background(
(icon == selection ? tint.opacity(0.15) : Color.secondary.opacity(0.08)),
in: RoundedRectangle(cornerRadius: 8)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(tint, lineWidth: icon == selection ? 2 : 0)
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
/// A row of the canonical `availableColors` as tappable swatches, single-select.
private struct ColorSwatchPicker: View {
@Binding var selection: String
private let columns = [GridItem(.adaptive(minimum: 36), spacing: 10)]
var body: some View {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(availableColors, id: \.self) { name in
Button {
selection = name
} label: {
Circle()
.fill(Color.color(from: name))
.frame(width: 28, height: 28)
.overlay(
Circle()
.strokeBorder(.primary, lineWidth: name == selection ? 2 : 0)
.padding(-2)
)
}
.buttonStyle(.plain)
.help(name.capitalized)
}
}
.padding(.vertical, 4)
}
}
@@ -0,0 +1,214 @@
import SwiftUI
import SwiftData
/// Every bundled starter routine including deleted ones with a per-seed Add/Restore
/// action and a bulk "re-add everything missing" fallback. Reads straight from
/// `SeedLibrary.seeds` (the bundle source of truth), so a deleted starter still has a
/// row. Presented as a sheet from the sidebar; selecting a seed pushes its full plan.
/// Mirrors iOS `StarterGalleryView`.
struct MacStarterGalleryView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@State private var isRestoringAll = false
@State private var restoreAllMessage: String?
var body: some View {
NavigationStack {
List {
Section {
ForEach(SeedLibrary.seeds, id: \.id) { seed in
NavigationLink(value: seed.id) {
row(for: seed)
}
}
} footer: {
Text("Every routine Workouts ships with, whether or not you've kept it. Adding one never touches a routine you've created or edited.")
.font(.caption)
.foregroundStyle(.secondary)
}
if let restoreAllMessage {
Section {
Text(restoreAllMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Starter Routines")
.navigationDestination(for: String.self) { seedID in
if let seed = SeedLibrary.seed(id: seedID) {
MacStarterPreview(seed: seed)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem {
Button {
Task {
isRestoringAll = true
restoreAllMessage = nil
let n = await syncEngine.restoreSeeds()
isRestoringAll = false
restoreAllMessage = n == 0
? "All starter routines are already present."
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
}
} label: {
Label("Re-add All Missing", systemImage: "arrow.counterclockwise")
}
.disabled(isRestoringAll || syncEngine.iCloudStatus != .available)
.help("Re-add every starter routine you've removed")
}
}
}
.frame(width: 520, height: 560)
}
@ViewBuilder
private func row(for seed: SeedLibrary.Seed) -> some View {
HStack {
content(for: seed.doc)
Spacer()
switch seed.state(among: routines) {
case .added:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
case .nameTaken:
EmptyView()
case .restorable:
Button("Add") {
Task { _ = await syncEngine.restoreSeed(id: seed.id) }
}
.buttonStyle(.borderless)
.disabled(syncEngine.iCloudStatus != .available)
}
}
}
private func content(for doc: RoutineDocument) -> some View {
HStack(spacing: 10) {
Image(systemName: doc.systemImage)
.font(.body)
.foregroundStyle(Color.color(from: doc.color))
.frame(width: 28, height: 28)
.background(Color.color(from: doc.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 1) {
Text(doc.name)
Text("\(doc.exercises.count) exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
/// Read-only preview of one bundled starter icon, activity type, full exercise plan
/// with the state-aware install action. Mirrors iOS `StarterPreviewView`.
private struct MacStarterPreview: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
let seed: SeedLibrary.Seed
@State private var isAdding = false
private var state: StarterSeedState { seed.state(among: routines) }
private var activityType: WorkoutActivityType {
seed.doc.activityType.flatMap(WorkoutActivityType.init(rawValue:)) ?? .traditionalStrength
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
Divider()
exerciseList
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.safeAreaInset(edge: .bottom) { actionBar }
.navigationTitle(seed.doc.name)
}
private var header: some View {
HStack(spacing: 14) {
Image(systemName: seed.doc.systemImage)
.font(.largeTitle)
.foregroundStyle(Color.color(from: seed.doc.color))
.frame(width: 56, height: 56)
.background(Color.color(from: seed.doc.color).opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 4) {
Text(seed.doc.name)
.font(.title2.weight(.semibold))
Label(activityType.displayName, systemImage: activityType.systemImage)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
private var exerciseList: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Exercises").font(.headline)
ForEach(seed.doc.exercises.sorted { $0.order < $1.order }) { exercise in
VStack(alignment: .leading, spacing: 2) {
Text(exercise.name)
Text(exercise.planSummary(weightUnit: weightUnit))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
@ViewBuilder
private var actionBar: some View {
Group {
switch state {
case .added:
Label("Added to My Routines", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.frame(maxWidth: .infinity)
case .nameTaken:
Text("A routine named \"\(seed.doc.name)\" already exists.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
case .restorable:
Button {
Task {
isAdding = true
let added = await syncEngine.restoreSeed(id: seed.id)
isAdding = false
if added { dismiss() }
}
} label: {
HStack {
if isAdding { ProgressView().controlSize(.small) }
Text(isAdding ? "Adding…" : "Add to My Routines")
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(isAdding || syncEngine.iCloudStatus != .available)
}
}
.padding()
.background(.thinMaterial)
}
}
@@ -0,0 +1,229 @@
import IndieSync
import SwiftUI
import SwiftData
/// Add or edit a schedule: pick a routine, choose a recurrence (once / daily / fixed
/// days), tag it with a goal, and optionally set a reminder time. Mac-native sheet
/// (grouped Form, fixed width, Cancel/Save) mirrors iOS `ScheduleAddEditView` minus
/// the "Now" start-immediately choice (the Mac never runs a workout) and the
/// notification-permission footer (the Mac never schedules a notification; the
/// iPhone's `ReminderScheduler` resyncs once it observes this document). Fields are
/// snapshotted in `init` so the sheet never holds the live entity.
struct MacScheduleEditorSheet: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
/// nil id add mode; a value edit mode (id / order / createdAt preserved).
@State private var scheduleID: String?
@State private var order: Int
@State private var createdAt: Date
@State private var routineID: String
/// The schedule's routine name when it was created surfaced when that routine
/// has since been deleted so the user knows what they're reassigning. Mirrors iOS.
@State private var originalRoutineName: String
@State private var goal: GoalKind?
@State private var recurrence: ScheduleRecurrence
@State private var weekdays: Set<Int>
@State private var date: Date
@State private var reminderEnabled: Bool
@State private var reminderTime: Date
init(schedule: Schedule?) {
_scheduleID = State(initialValue: schedule?.id)
_order = State(initialValue: schedule?.order ?? 0)
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
_routineID = State(initialValue: schedule?.routineID ?? "")
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
_goal = State(initialValue: schedule?.goalKind)
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_date = State(initialValue: schedule?.date ?? Date())
_reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil)
_reminderTime = State(initialValue: ScheduleEditing.time(fromMinutes: schedule?.reminderMinutes))
}
private var isEditing: Bool { scheduleID != nil }
/// The live routine currently selected, following the seed clone-on-edit redirect
/// (a schedule made against a starter seed keeps the retired seed id after the
/// routine is edited); nil when the routine has been deleted. Mirrors iOS
/// `ScheduleAddEditView.selectedRoutine`.
private var selectedRoutine: Routine? {
guard !routineID.isEmpty else { return nil }
let liveID = syncEngine.currentRoutineID(for: routineID)
return routines.first { $0.id == liveID }
}
private var canSave: Bool {
guard selectedRoutine != nil else { return false }
if recurrence == .fixedDays { return !weekdays.isEmpty }
return true
}
var body: some View {
NavigationStack {
Form {
routineSection
recurrenceSection
goalSection
reminderSection
}
.formStyle(.grouped)
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save", action: save)
.disabled(!canSave)
}
}
.onAppear {
// Default to the first routine when adding (the @Query isn't populated at init).
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
}
}
.frame(width: 460, height: 560)
}
// MARK: - Sections
private var routineSection: some View {
Section {
Picker("Routine", selection: routineSelectionBinding) {
if selectedRoutine == nil {
Text("Please select").tag("")
}
ForEach(routines) { routine in
Label(routine.name, systemImage: routine.systemImage)
.tag(routine.id)
}
}
.labelsHidden()
} header: {
Text("Routine")
} footer: {
if selectedRoutine == nil, !originalRoutineName.isEmpty {
Text("\"\(originalRoutineName)\" is no longer available. Choose a routine.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// The Picker always shows the *resolved* current selection (following a
/// clone-on-edit redirect), but writes back the raw id the user picked save()
/// re-resolves through `selectedRoutine` regardless.
private var routineSelectionBinding: Binding<String> {
Binding(
get: { selectedRoutine?.id ?? "" },
set: { routineID = $0 }
)
}
@ViewBuilder
private var recurrenceSection: some View {
Section {
Picker("When", selection: $recurrence) {
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
Text(r.displayName).tag(r)
}
}
.pickerStyle(.segmented)
.labelsHidden()
switch recurrence {
case .daily:
EmptyView()
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
.datePickerStyle(.field)
case .fixedDays:
weekdayPicker
}
} header: {
Text("When")
}
}
private var goalSection: some View {
Section {
Picker("Goal", selection: $goal) {
Text("None").tag(GoalKind?.none)
ForEach(GoalKind.orderedCases()) { kind in
Label(kind.displayName, systemImage: kind.systemImage)
.foregroundStyle(Color.color(from: kind.colorName))
.tag(GoalKind?.some(kind))
}
}
} header: {
Text("Goal")
}
}
private var reminderSection: some View {
Section {
Toggle("Remind Me", isOn: $reminderEnabled)
if reminderEnabled {
DatePicker("Time", selection: $reminderTime, displayedComponents: .hourAndMinute)
.datePickerStyle(.field)
}
} header: {
Text("Reminder")
} footer: {
Text("The Mac doesn't send reminders itself — your iPhone schedules the notification once it syncs this schedule.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
/// A row of toggleable weekday chips, Monday-first. Mirrors iOS
/// `ScheduleAddEditView.weekdayPicker`.
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(ScheduleEditing.weekdayOrder, id: \.self) { n in
let on = weekdays.contains(n)
Button {
if on { weekdays.remove(n) } else { weekdays.insert(n) }
} label: {
Text(ScheduleEditing.weekdaySymbols[n - 1])
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(on ? Color.accentColor : Color.secondary.opacity(0.15), in: Capsule())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
// MARK: - Save
private func save() {
guard let routine = selectedRoutine else { return }
let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion,
id: scheduleID ?? ULID.make(),
routineID: routine.id,
routineName: routine.name,
goal: goal?.rawValue,
recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
date: recurrence == .once ? date : nil,
reminderMinutes: reminderEnabled ? ScheduleEditing.minutesFromMidnight(of: reminderTime) : nil,
order: isEditing ? order : ScheduleEditing.nextOrder(in: modelContext),
createdAt: createdAt,
updatedAt: Date()
)
Task { await syncEngine.save(schedule: doc) }
dismiss()
}
}
@@ -0,0 +1,176 @@
import SwiftUI
import SwiftData
/// A schedule flagged for deletion captured as plain values (id + a display name),
/// not a retained `@Model`. Reading a persisted property on a since-deleted entity
/// traps (the repo's known crash class), so the confirmation dialog reads only these
/// captured strings and re-resolves the live schedule by id at delete time. Mirrors
/// `MacContentView.PendingRoutineDelete`.
private struct PendingScheduleDelete: Identifiable {
let id: String
let name: String
}
/// A schedule flagged for editing captured as just its id, not a retained `@Model`
/// (see `PendingScheduleDelete`). The sheet re-resolves the live schedule by id when it
/// presents, so a remote delete between the tap and the sheet's next body evaluation
/// can't hand the editor a dangling entity.
private struct PendingScheduleEdit: Identifiable {
let id: String
}
/// The Schedules management pane: every `Schedules/<ULID>.json` document as a row
/// (routine name, recurrence summary, reminder time, goal tag), with add/edit/delete.
/// All writes go through `syncEngine.save(schedule:)` / `delete(schedule:)` never the
/// SwiftData cache directly. Mac does not schedule notifications; the iPhone's
/// `ReminderScheduler` resyncs from the document once it observes the change. Mirrors
/// iOS `TodayView`'s schedule rows, sans the per-day "today" status (Mac doesn't run
/// workouts).
struct MacSchedulesView: View {
@Environment(SyncEngine.self) private var syncEngine
// Mirrors iOS `TodayView`'s schedule ordering.
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
private var schedules: [Schedule]
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@State private var showingNewSchedule = false
@State private var scheduleToEdit: PendingScheduleEdit?
@State private var pendingDelete: PendingScheduleDelete?
var body: some View {
Group {
if schedules.isEmpty {
ContentUnavailableView(
"No Schedules",
systemImage: "calendar",
description: Text("Schedule a routine to see it here.")
)
} else {
List(schedules) { schedule in
ScheduleRow(schedule: schedule, routine: resolvedRoutine(for: schedule))
.contentShape(Rectangle())
.onTapGesture(count: 2) { scheduleToEdit = PendingScheduleEdit(id: schedule.id) }
.contextMenu {
Button("Edit…") { scheduleToEdit = PendingScheduleEdit(id: schedule.id) }
Divider()
Button("Delete", role: .destructive) {
pendingDelete = PendingScheduleDelete(
id: schedule.id,
name: resolvedRoutine(for: schedule)?.name ?? schedule.routineName)
}
}
}
}
}
.navigationTitle("Schedules")
.toolbar {
ToolbarItem {
Button {
showingNewSchedule = true
} label: {
Label("New Schedule…", systemImage: "plus")
}
.help("New Schedule")
}
}
.sheet(isPresented: $showingNewSchedule) {
MacScheduleEditorSheet(schedule: nil)
}
.sheet(item: $scheduleToEdit) { pending in
if let schedule = schedules.first(where: { $0.id == pending.id }), schedule.isLive {
MacScheduleEditorSheet(schedule: schedule)
}
}
.confirmationDialog(
"Delete Schedule?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
titleVisibility: .visible,
presenting: pendingDelete
) { pending in
Button("Delete", role: .destructive) { confirmDelete(pending) }
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: { pending in
Text("This removes \"\(pending.name)\" from your schedule. The routine itself is not affected.")
}
}
// MARK: - Actions
/// The live routine this schedule points at, following the seed clone-on-edit
/// redirect; nil when the routine has been deleted. Mirrors iOS
/// `TodayView.resolvedRoutine(for:)`.
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
let liveID = syncEngine.currentRoutineID(for: schedule.routineID)
return routines.first { $0.id == liveID }
}
private func confirmDelete(_ pending: PendingScheduleDelete) {
if let schedule = schedules.first(where: { $0.id == pending.id }), schedule.isLive {
Task { await syncEngine.delete(schedule: schedule) }
}
pendingDelete = nil
}
}
/// One schedule row: a color badge (the routine's own icon/color, falling back to the
/// goal's color or plain secondary when the routine is gone), the routine name
/// (falling back to the schedule's denormalized `routineName`), the recurrence summary,
/// an optional goal tag, and the reminder time if one is set.
private struct ScheduleRow: View {
let schedule: Schedule
/// The resolved live routine, or nil when it's been deleted a dangling
/// `routineID` is handled the same way iOS's `TodayView` handles it: fall back to
/// the schedule's own denormalized name/goal color rather than showing an error.
let routine: Routine?
var body: some View {
HStack(spacing: 12) {
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
.font(.title3)
.foregroundStyle(badgeColor)
.frame(width: 32, height: 32)
.background(badgeColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(routine?.name ?? schedule.routineName)
HStack(spacing: 8) {
Text(schedule.recurrenceSummary)
.font(.caption)
.foregroundStyle(.secondary)
if let goal = schedule.goalKind {
Label(goal.displayName, systemImage: goal.systemImage)
.font(.caption)
.foregroundStyle(Color.color(from: goal.colorName))
}
}
}
Spacer()
if let minutes = schedule.reminderMinutes {
Label(Self.timeString(fromMinutes: minutes), systemImage: "bell.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 3)
}
private var badgeColor: Color {
if let routine { return Color.color(from: routine.color) }
return Color.color(from: schedule.goalKind?.colorName ?? "")
}
/// Render minutes-from-midnight (local wall-clock) as a short time string, e.g.
/// "8:00 AM" the same interpretation `ScheduleAddEditView` writes on save.
private static func timeString(fromMinutes minutes: Int) -> String {
ScheduleEditing.time(fromMinutes: minutes).formattedTime()
}
}
+55
View File
@@ -0,0 +1,55 @@
//
// WorkoutsMacApp.swift
// Workouts Mac
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
@main
struct WorkoutsMacApp: App {
@State private var services = MacAppServices()
@Environment(\.scenePhase) private var scenePhase
@State private var lastActivationReconcile: Date?
/// `.active` also fires on every window focus-in (-Tab back), not just launch
/// the metadata observer covers live changes while running, so the activation
/// reconcile only needs to catch changes synced while the app was completely
/// inactive for a while.
private static let activationReconcileInterval: TimeInterval = 5 * 60
var body: some Scene {
WindowGroup {
MacRootGateView()
.environment(services)
.environment(services.syncEngine)
.modelContainer(services.container)
.task { await services.bootstrap() }
}
.defaultSize(width: 1000, height: 700)
// The macOS iCloud quirk: the ubiquity container can keep syncing while the
// app isn't running, so re-check on every foreground return rather than
// relying on the (iOS-only) metadata observer alone.
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
if services.syncEngine.iCloudStatus == .unavailable {
Task { await services.syncEngine.connect() }
} else if services.syncEngine.iCloudStatus == .available {
let now = Date()
if lastActivationReconcile == nil
|| now.timeIntervalSince(lastActivationReconcile!) > Self.activationReconcileInterval {
lastActivationReconcile = now
Task { await services.syncEngine.rebuildCache() }
}
}
case .background, .inactive:
Task { await services.syncEngine.flushPendingWrites() }
@unknown default:
break
}
}
}
}
+50
View File
@@ -4,6 +4,7 @@ options:
deploymentTarget:
iOS: "26.0"
watchOS: "26.0"
macOS: "26.0"
xcodeVersion: "26.0"
defaultConfig: Debug
@@ -184,3 +185,52 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
TARGETED_DEVICE_FAMILY: "1"
# ---- macOS app (library management MVP: routines/schedules/exercises; no
# workout running/editing). Same bundle id as the iOS app (universal
# purchase); writes only Splits/ and Schedules/ documents. -------------
Workouts Mac:
type: application
platform: macOS
sources:
- path: Shared
excludes:
- "HealthKit/**"
- path: Workouts/Sync
- path: Workouts/Seed
- path: Workouts/ExerciseFigure
- path: Workouts/Views/Exercises/ExerciseCatalog.swift
- path: "Workouts/Views/Exercises/ExerciseDocument+PlanSummary.swift"
- path: Workouts/Views/Exercises/ExerciseInfoContent.swift
- path: Workouts/Views/Routines/RoutineOrdering.swift
- path: Workouts/Views/Library/StarterSeedState.swift
- path: Workouts/Assets.xcassets
- path: Workouts/Resources/ExerciseMotions
buildPhase: resources
- path: Workouts/Resources/StarterSplits
buildPhase: resources
- path: "Workouts Mac"
excludes:
- "Resources/Info-*.plist"
- "Resources/*.entitlements"
- path: CHANGELOG.md
buildPhase: resources
type: file
- path: LICENSE.md
buildPhase: resources
type: file
dependencies:
- package: IndieSync
- package: IndieAbout
postBuildScripts: *updateBuildInfo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts
PRODUCT_NAME: Workouts
INFOPLIST_FILE: "Workouts Mac/Resources/Info-macOS.plist"
CODE_SIGN_ENTITLEMENTS: "Workouts Mac/Resources/Workouts-macOS.entitlements"
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
MACOSX_DEPLOYMENT_TARGET: "26.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor