The exercise picker now lists the bundled exercise library — the motion-rig exercises exported from Exercise Library/ — instead of the two *.exercises.yaml starter catalogs. ExerciseListLoader and the Yams dependency are removed; ExerciseMotionLibrary gains exerciseNames, the bundle enumeration the picker reads.
10 KiB
10 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
Development Commands
Project Generation
- The Xcode project is generated by XcodeGen from
project.yml. After editingproject.yml(targets, sources, packages, settings), regenerate withxcodegen generate. - Do not hand-edit
Workouts.xcodeproj— it is generated and your changes will be overwritten.
Building and Running
- Build the project: Open
Workouts.xcodeprojin Xcode and build (Cmd+B) - Run on iOS Simulator: Select the Workouts scheme and run (Cmd+R)
- Run on Apple Watch Simulator: Select the "Workouts Watch App" scheme and run
Architecture
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>.jsonandWorkouts/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 withcloudKitDatabase: .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(anNSMetadataQuery) emits a delta batch →CacheMapperupserts SwiftData →@Queryviews refresh. The phone is the sole writer of iCloud Drive. - Key types:
SyncEngine(@Observable @MainActor, inWorkouts/Sync/) — the app-specific orchestrator.connect()resolves the ubiquity container and reconciles;save/deletewrite files;handle(_:)applies observer delta batches;ingestFromWatch(_:)writes + upserts the cache directly. ExposesiCloudStatusand anonCacheChangedcallback.- The file-side core comes from the IndieSync SPM package (
https://git.rzen.dev/rzen/indie-sync.git):DocumentFileStore(actor — allNSFileCoordinatorI/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration),TombstoneStore(soft-delete stubs, resurrection veto, grace-period pruning),MetadataObserver(@MainActor— wraps theNSMetadataQuery, emitting added/modified/removed batches as anAsyncStream), plusULID,DocumentCoder,SyncError, and theVersionedDocumentprotocol. See theicloud-sync-engineskill for the architecture invariants.
- Soft deletes: a delete writes a
TombstonetoStubs/<id>.jsonthen removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period — except starter-seed stubs, which are exempt (prune(exempting:)) and veto seed resurrection forever. - iCloud is required:
RootGateView(Workouts/Views/) gates the app onSyncEngine.iCloudStatus— there is no local-only mode.
Data Layer (three shapes + a stateless mapper)
All in Shared/Model/:
- Codable documents (
Documents.swift) — the on-disk / wire format:SplitDocument(embeds[ExerciseDocument]) andWorkoutDocument(embeds[WorkoutLogDocument]), conforming to IndieSync'sVersionedDocumentschema gate (quarantines files from newer app versions).TombstoneandDocumentCodercome from IndieSync.WorkoutDocument.relativePathkeeps the app's own local-calendar month bucketing (predates IndieSync's UTCTimeBucketedLayout; changing it would strand existing files). - SwiftData
@Modelcache entities (Entities.swift) —Split,Exercise,Workout,WorkoutLog; each keyed by a stableid: StringULID (@Attribute(.unique)), with cascade relationships and ajsonRelativePathback to its file. - Stateless mappers (
Mappers.swift) —init(from: entity)for cache → document;CacheMapper.upsert…for document → cache (used only by the observer, reconcile, and the watch bridge). - Identifiers (
ULID.swift) — a thin shim over IndieSync'sULID:ULID.make()mints the 26-char Crockford base32 string (chronologically sortable) that documents and entities key on. - Enums (
Enums.swift) —WorkoutStatus,LoadType.
Core Aggregates
- Split → embeds many Exercise (a single document)
- Workout → embeds many WorkoutLog (a single document); references its split only by denormalized
splitID/splitName— there is no live relationship, workouts are independent documents.
App Structure & DI
- iOS:
WorkoutsApp(@main) ownsAppServices(@Observable @MainActor:container,syncEngine,watchBridge,workoutLauncher), injects them via.environment(...)/.modelContainer(...), then runsawait services.bootstrap()(connect + activate). Root isRootGateView→ContentView→WorkoutLogsView(a single screen — there is no TabView). - watchOS:
WorkoutsWatchApp(@main) ownsWatchAppServices(container+bridge; no iCloud, no SyncEngine) and aWatchAppDelegate. Root isContentView→ActiveWorkoutGateView. - Views read services with
@Environment(SyncEngine.self)(etc.), read data with@Query, and write withawait 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.
- Wire format
Shared/Connectivity/WCPayload.swift. Phone→Watch: full state (splits + recent workouts) plus settings (rest seconds, done-countdown seconds) viaupdateApplicationContext(latest-wins). Watch→Phone:workoutUpdate(oneWorkoutDocument) andrequestSync. - Phone
Workouts/Connectivity/PhoneConnectivityBridge.swift— pushes ononCacheChanged; inbound updates go toSyncEngine.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):
WorkoutSessionManagerholds anHKWorkoutSessionso the watch stays foregrounded during a workout; the phone launches it viaWorkouts/HealthKit/WorkoutLauncher.swift.
Platforms, Tooling & Entitlements
- iOS 26 / watchOS 26; Swift 6 with
SWIFT_STRICT_CONCURRENCY: completeon both targets. - Build number (git commit count),
BuildDate, andBuildHashare stamped by theapp-versioningskill'supdate_build_info.sh(referenced in place from../indie-skills, a post-build phase; release tagging is opt-in viaRELEASE_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.
- SPM packages:
IndieSync(file-side iCloud sync core) andIndieAbout(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/, plusWorkoutSessionManager,WatchAppDelegate,WatchAppServices- Root:
project.yml,Scripts/, andCHANGELOG.md/README.md/LICENSE.md(bundled into the iOS app for IndieAbout)
Starter Data (deterministic seeds)
- Starter splits: shipped as byte-canonical
SplitDocumentJSON inWorkouts/Resources/StarterSplits/*.split.jsonwith fixed ULIDs (shared01DXF6DT00prefix, frozen 2020 timestamp) and fixed content, regenerated only byScripts/generate_starter_splits.swift.SeedLibrary(Workouts/Seed/) loads the catalog; seeds are immutable —SyncEngine.save(split:)transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap viasync.currentSplitID(for:)). - Auto-seed:
SyncEngine.autoSeedIfEmpty()writes the verbatim bundle bytes after connect, only into a verifiably empty container (no data files, no stubs) re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and stubs reap resurrected seeds. The on-demand path (SplitSeeder.seedDefaults, "Add Starter Splits") restores deleted seeds (lifting the veto stub) or writes missing ones, skipping live names. - Exercise library: authored in
Exercise Library/at the repo root (per-exerciseinfo.md, SVG visuals, motion rigs, Python render pipeline); the app bundles the exportedWorkouts/Resources/ExerciseMotions/*.motion.jsonrigs, which double as the exercise picker's list (ExerciseMotionLibrary.exerciseNames).
Guidelines
Data Model Changes
- A new persisted field must be added in three places: the Codable
…Document(on-disk shape), the@Modelcache entity, and both directions of the mapper inMappers.swift. - Bump a document's
currentSchemaVersionwhen its on-disk shape changes incompatibly. - All writes go through
SyncEngine(save/delete) — never mutate the SwiftData cache directly, since it is rebuilt from files. - Mint IDs with
ULID.make(); they are stable across cache rebuilds.
Concurrency
- Code is Swift 6 strict-concurrency clean. UI and orchestration types are
@MainActor; file I/O lives in IndieSync'sDocumentFileStoreactor.
UI
- SwiftUI-first:
NavigationStack,@Query-backed lists, form-based add/edit, sheet presentations, and swipe actions. - Theming via
Color+Extensions.swift; date formatting viaDate+Extensions.swift(both inShared/Utils/).
Authoring the Changelog
CHANGELOG.md is bundled and shown in-app via IndieAbout — follow the app-changelog skill (single source of truth for format and workflow).