Native iOS + Apple Watch workout tracker that parses LLM-generated text plans into structured sessions with step-by-step timers, GPS tracking, and HealthKit integration.
This project doubles as a testbed for a multi-subagent Claude Code review workflow (see .claude/agents/ — dedicated coder, reviewer, tester, and doc-writer agents). Findings from that process are written up honestly in docs/CODE_REVIEW.md rather than silently fixed and forgotten; a few are still open.
Why the dual-path LLM routing? Plans of roughly ≤3,500 tokens are parsed on-device via Apple Foundation Models (@Generable, iOS 26+) — no network round-trip, no API key. Longer plans, or devices on iOS 17, fall back to Gemini 2.0 Flash Lite via OpenRouter. Tradeoff: two parser implementations that both have to stay compatible with the same ParsedWorkoutPlan output contract, in exchange for not forcing an API key on the common case.
Real tradeoffs the review surfaced (see CODE_REVIEW.md for the full list):
- The SwiftData
ModelContainerfallback path force-unwraps (try!) — if the fallback container itself fails to initialize, the app crashes on launch with no recovery (WorkoutTrackerApp.swift,WatchApp.swift). ExecutionStep.flattenStepscreatesExercisemodel objects for rest intervals but never inserts them into aModelContext, leaving orphaned/unmanaged SwiftData instances.LocationServiceand the WatchWorkoutSessionManagermutate@ObservableGPS state directly fromCLLocationManagerDelegatecallbacks, which fire on an arbitrary thread — a data race, since@Observableisn't thread-safe.- The OpenRouter API key ships via
Info.plist/xcconfig, which is extractable from the compiled IPA — fine for a personal project, not something to ship at scale.
What I'd change next: WatchConnectivityManager.shared (Shared/Services/WatchConnectivityManager.swift) is still a hard singleton, used directly by SessionExecutionViewModel, WatchApp, and WorkoutSessionManager — CODE_REVIEW.md flags this as blocking unit tests without swizzling (H4/T1). HealthKit and CoreLocation, by contrast, already sit behind HealthKitServiceProtocol/LocationServiceProtocol with service-level tests in Tests/ViewModels/SessionExecutionViewModelTests.swift; extending that same DI pattern to Watch connectivity is the logical next step.
- Paste & Parse — paste any text workout plan; on-device AI (Apple Intelligence, iOS 18+) or Gemini 2.0 Flash Lite (cloud fallback) structures it into sessions, blocks, and exercises
- Session Tracker — step-by-step execution with countdown timers, exercise instructions, HR zone display, and RPE
- GPS Tracking — live pace, distance, and route map (MapKit) for running and cycling blocks
- Apple Watch — mirrors the active iPhone session with haptics; can also run sessions independently with on-Watch GPS + HR via HealthKit
- iCloud Sync — plans and history sync across your iPhone and Watch via CloudKit
- Accessibility — full VoiceOver support across all screens
- Structured Logging — unified diagnostics via
os.Loggerthroughout the app
- Xcode 15+
- iOS 17+ (iPhone target)
- watchOS 10+ (Apple Watch target)
- Active Apple Developer account (for HealthKit, CloudKit, and Watch)
- XcodeGen to generate the
.xcodeproj
- Xcode 15+
- XcodeGen
# Clone the repo
git clone https://github.com/victorkzam/workout-plan-tracker.git
cd workout-plan-tracker
# Copy secrets template
cp Secrets.xcconfig.template Secrets.xcconfig
# Edit Secrets.xcconfig with your OpenRouter API key
# Generate Xcode project
xcodegen generate
# Open in Xcode
open WorkoutTracker.xcodeprojThe app uses Apple Foundation Models on-device (iOS 18+) as the primary parser — no API key needed for that path.
For the cloud fallback (iOS 17 or long plans), create Secrets.xcconfig:
cp Secrets.xcconfig.template Secrets.xcconfigEdit Secrets.xcconfig and set your OpenRouter key:
OPENROUTER_API_KEY = sk-or-v1-YOUR_KEY_HERE
Secrets.xcconfigis gitignored and will never be committed.
Open WorkoutTracker.xcodeproj, select the WorkoutTracker and WorkoutTrackerWatch targets, and set your Team in Signing & Capabilities.
In project.yml, set:
settings:
base:
DEVELOPMENT_TEAM: "YOUR_TEAM_ID"Then re-run xcodegen generate.
In the CloudKit Console, create a container named:
iCloud.com.victorkzam.WorkoutTracker
xcodegen generate
xcodebuild test -scheme WorkoutTrackerTests -destination 'platform=iOS Simulator,name=iPhone 16'swiftlint lintBuild and run the WorkoutTracker scheme on your iPhone. The Watch app is automatically pushed if a paired Watch is connected.
workout-plan-tracker/
├── Shared/ # Shared between iOS + Watch (SwiftData models, WatchConnectivity)
│ ├── Models/ # WorkoutPlan, WorkoutSession, WorkoutBlock, Exercise, SessionExecution
│ ├── Protocols/ # Service protocol abstractions
│ ├── Services/ # WatchConnectivityManager
│ └── Utilities/ # Shared utilities (GPSMath, Logging, ModelContainerFactory)
├── iOS/
│ ├── App/ # App entry point, Info.plist, entitlements
│ ├── Services/ # WorkoutParserService, AppleFoundationParser, OpenRouterParser,
│ │ # HealthKitService, LocationService
│ ├── ViewModels/ # PlanImportViewModel, SessionExecutionViewModel
│ └── Views/
│ ├── Plans/ # PlanListView, PasteImportView, PlanDetailView, SessionDetailView
│ ├── Sessions/ # SessionExecutionView, ExerciseStepView, GPSRunView
│ └── Components/ # HRZoneTag, PaceDisplay, BlockTypeBadge
├── Watch/
│ ├── WatchApp.swift # Entry point + WatchSessionController
│ ├── Services/ # WorkoutSessionManager (HKWorkoutSession + GPS)
│ └── Views/ # SessionListWatchView, SessionWatchView, GPSWatchView, ...
├── Tests/ # Unit tests with mocks
├── .claude/agents/ # Claude Code subagent definitions
└── docs/ # Project documentation
| Path | When used | Model |
|---|---|---|
| On-device | iOS 26+, plan ≤ 3 500 tokens | Apple Foundation Models (@Generable) |
| Cloud (OpenRouter) | iOS 17, or plan > 3 500 tokens | Gemini 2.0 Flash Lite |
Both paths produce the same ParsedWorkoutPlan struct, which is converted to SwiftData models.
- Paste the sample half-marathon plan → 4 sessions created, Session 1 has 5 blocks
- Core circuit block:
rounds=2,workIntervalSec=45,restIntervalSec=15 - Run block has
exerciseType=gpsRun, pace and HR zone populated - Start Session 1 → warm-up steps auto-advance every 30 s
- Core circuit repeats × 2 rounds with 15 s rest steps between exercises
- GPS run screen shows live pace + route polyline
- Start session on iPhone → Watch mirrors current step within 3 s
- Start GPS run from Watch →
HKWorkoutSessionrecords distance + HR independently - Plan added on iPhone → appears on Watch after CloudKit sync