From cbdf02bca7a90cf40db94b5c29024ae96120a303 Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 16 Jul 2026 19:55:06 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + CLAUDE.md | 12 +- README.md | 15 +- Workouts Mac/MacAppServices.swift | 35 ++ Workouts Mac/Resources/Info-macOS.plist | 42 +++ .../Resources/Workouts-macOS.entitlements | 20 ++ .../Exercises/MacExercisesBrowserView.swift | 123 +++++++ Workouts Mac/Views/MacContentView.swift | 246 ++++++++++++++ Workouts Mac/Views/MacRootGateView.swift | 305 ++++++++++++++++++ .../Routines/MacExerciseEditorSheet.swift | 297 +++++++++++++++++ .../Views/Routines/MacRoutineDetailView.swift | 219 +++++++++++++ .../Routines/MacRoutineEditorSheet.swift | 181 +++++++++++ .../Routines/MacStarterGalleryView.swift | 214 ++++++++++++ .../Schedules/MacScheduleEditorSheet.swift | 229 +++++++++++++ .../Views/Schedules/MacSchedulesView.swift | 176 ++++++++++ Workouts Mac/WorkoutsMacApp.swift | 55 ++++ project.yml | 50 +++ 17 files changed, 2212 insertions(+), 9 deletions(-) create mode 100644 Workouts Mac/MacAppServices.swift create mode 100644 Workouts Mac/Resources/Info-macOS.plist create mode 100644 Workouts Mac/Resources/Workouts-macOS.entitlements create mode 100644 Workouts Mac/Views/Exercises/MacExercisesBrowserView.swift create mode 100644 Workouts Mac/Views/MacContentView.swift create mode 100644 Workouts Mac/Views/MacRootGateView.swift create mode 100644 Workouts Mac/Views/Routines/MacExerciseEditorSheet.swift create mode 100644 Workouts Mac/Views/Routines/MacRoutineDetailView.swift create mode 100644 Workouts Mac/Views/Routines/MacRoutineEditorSheet.swift create mode 100644 Workouts Mac/Views/Routines/MacStarterGalleryView.swift create mode 100644 Workouts Mac/Views/Schedules/MacScheduleEditorSheet.swift create mode 100644 Workouts Mac/Views/Schedules/MacSchedulesView.swift create mode 100644 Workouts Mac/WorkoutsMacApp.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c34296..8d44097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 8ff7af9..c74a0f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.json` and `Workouts/YYYY/MM/.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) diff --git a/README.md b/README.md index c9f97a1..e641e8a 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/Workouts Mac/MacAppServices.swift b/Workouts Mac/MacAppServices.swift new file mode 100644 index 0000000..fa09de3 --- /dev/null +++ b/Workouts Mac/MacAppServices.swift @@ -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? + + 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 + } +} diff --git a/Workouts Mac/Resources/Info-macOS.plist b/Workouts Mac/Resources/Info-macOS.plist new file mode 100644 index 0000000..8888e16 --- /dev/null +++ b/Workouts Mac/Resources/Info-macOS.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Workouts + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSApplicationCategoryType + public.app-category.healthcare-fitness + NSUbiquitousContainers + + iCloud.dev.rzen.indie.Workouts + + NSUbiquitousContainerIsDocumentScopePublic + + NSUbiquitousContainerName + Workouts + NSUbiquitousContainerSupportedFolderLevels + Any + + + + diff --git a/Workouts Mac/Resources/Workouts-macOS.entitlements b/Workouts Mac/Resources/Workouts-macOS.entitlements new file mode 100644 index 0000000..426d01a --- /dev/null +++ b/Workouts Mac/Resources/Workouts-macOS.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.developer.icloud-container-identifiers + + iCloud.dev.rzen.indie.Workouts + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + iCloud.dev.rzen.indie.Workouts + + + diff --git a/Workouts Mac/Views/Exercises/MacExercisesBrowserView.swift b/Workouts Mac/Views/Exercises/MacExercisesBrowserView.swift new file mode 100644 index 0000000..5ecd9c2 --- /dev/null +++ b/Workouts Mac/Views/Exercises/MacExercisesBrowserView.swift @@ -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) + } + } +} diff --git a/Workouts Mac/Views/MacContentView.swift b/Workouts Mac/Views/MacContentView.swift new file mode 100644 index 0000000..d74e056 --- /dev/null +++ b/Workouts Mac/Views/MacContentView.swift @@ -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) } +} diff --git a/Workouts Mac/Views/MacRootGateView.swift b/Workouts Mac/Views/MacRootGateView.swift new file mode 100644 index 0000000..297b5e3 --- /dev/null +++ b/Workouts Mac/Views/MacRootGateView.swift @@ -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) + } +} diff --git a/Workouts Mac/Views/Routines/MacExerciseEditorSheet.swift b/Workouts Mac/Views/Routines/MacExerciseEditorSheet.swift new file mode 100644 index 0000000..89c3d86 --- /dev/null +++ b/Workouts Mac/Views/Routines/MacExerciseEditorSheet.swift @@ -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) } + } +} diff --git a/Workouts Mac/Views/Routines/MacRoutineDetailView.swift b/Workouts Mac/Views/Routines/MacRoutineDetailView.swift new file mode 100644 index 0000000..acd2f29 --- /dev/null +++ b/Workouts Mac/Views/Routines/MacRoutineDetailView.swift @@ -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) + } +} diff --git a/Workouts Mac/Views/Routines/MacRoutineEditorSheet.swift b/Workouts Mac/Views/Routines/MacRoutineEditorSheet.swift new file mode 100644 index 0000000..e6577d6 --- /dev/null +++ b/Workouts Mac/Views/Routines/MacRoutineEditorSheet.swift @@ -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())) ?? [] + 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) + } +} diff --git a/Workouts Mac/Views/Routines/MacStarterGalleryView.swift b/Workouts Mac/Views/Routines/MacStarterGalleryView.swift new file mode 100644 index 0000000..c4f2ad9 --- /dev/null +++ b/Workouts Mac/Views/Routines/MacStarterGalleryView.swift @@ -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) + } +} diff --git a/Workouts Mac/Views/Schedules/MacScheduleEditorSheet.swift b/Workouts Mac/Views/Schedules/MacScheduleEditorSheet.swift new file mode 100644 index 0000000..2d5e363 --- /dev/null +++ b/Workouts Mac/Views/Schedules/MacScheduleEditorSheet.swift @@ -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 + @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 { + 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() + } +} diff --git a/Workouts Mac/Views/Schedules/MacSchedulesView.swift b/Workouts Mac/Views/Schedules/MacSchedulesView.swift new file mode 100644 index 0000000..92eb81a --- /dev/null +++ b/Workouts Mac/Views/Schedules/MacSchedulesView.swift @@ -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/.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() + } +} diff --git a/Workouts Mac/WorkoutsMacApp.swift b/Workouts Mac/WorkoutsMacApp.swift new file mode 100644 index 0000000..5f1838e --- /dev/null +++ b/Workouts Mac/WorkoutsMacApp.swift @@ -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 + } + } + } +} diff --git a/project.yml b/project.yml index 9cfad1f..d7a5fe9 100644 --- a/project.yml +++ b/project.yml @@ -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