Whiteboard photo to round timer for iPhone
Native SwiftUI iPhone app that reads gym whiteboard photos with Vision OCR into workout rounds and runs an interval timer offline.
swiftswiftuivisionavfoundationswiftdataios
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add whiteboard-photo-to-round-timer-for-iphone-7cd746CLAUDE.md# the app
Native iPhone app. The user photographs a gym whiteboard. The app reads the text with Vision OCR, parses it into workout rounds (exercises, work seconds or reps, rest), lets the user review and edit the text, and runs an interval timer over the parsed plan. Everything runs on device with no network calls.
Scope and acceptance criteria are in @SPEC.md. The current phase and its /goal block are in @PLAN.md. Review rules are in @REVIEW.md.
## Stack
- Swift 5.10 or newer, SwiftUI, iOS 17 minimum.
- Vision (VNRecognizeTextRequest) for OCR, AVFoundation for camera capture.
- No third-party dependencies unless approved and listed in SPEC.md.
- Saved plans use SwiftData or Codable JSON, as decided in SPEC.md.
## Commands
Run from the project root.
- Build: `xcodebuild -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16' build`
- Test: `xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'`
- Lint (if installed): `swift-format lint -r Sources Tests`
- After any Swift change, run the `verify` skill.
- For test results, use the `test-runner` agent so raw logs stay out of the main context.
## Architecture
- `Parsing/`: pure Swift. Takes `[String]` of recognized lines and returns a `WorkoutPlan`. No UIKit or SwiftUI imports. Unit tested with text fixtures.
- `Timer/`: timer state machine. No UI imports. Driven by a clock protocol so tests advance time without sleeping.
- `Views/`: SwiftUI only.
- `Camera/`: capture, photo picker, and the VNRecognizeTextRequest wrapper.
- `Tests/Fixtures/images/`: sample whiteboard photos.
- `Tests/Fixtures/ocr/`: raw OCR text, same basename as the image.
- Expected plan JSON sits next to each OCR file.
## Rules
- Parser tests never call Vision. They feed `[String]` fixtures.
- Timer code never reads the system clock directly. Inject the clock protocol.
- Sort Vision observations by bounding box y, then x, before joining text. Vision's order is not reliable.
- The parser must tolerate misspellings and missing rest lines. Return warnings. Never crash.
- AMRAP and EMOM are text labels only in v1. Do not add timing behavior for them.
- No force unwraps and no `try!` outside tests.
- User-visible strings go in the String Catalog.
- No network code. v1 is offline.
## Gotchas
- The simulator has no camera. Photo flow tests use bundled sample images and the Photos picker path.
- Whiteboard handwriting is noisy. Expect misspellings and dropped lines.
- Do not edit an expected JSON fixture to make a test pass without stating the reason in your reply. The parse-whiteboard skill has the full steps.
- Keep sample photos free of gym members and faces.
- Info.plist needs NSCameraUsageDescription and NSPhotoLibraryUsageDescription before camera or photo code ships.
## Signing and secrets
- Use `TEAM_ID_PLACEHOLDER` in every committed file. Put the real team id only in local Xcode signing settings.
- Never commit signing certificates, provisioning profiles, or App Store Connect keys.
- Do not run fastlane match from a Claude session.
## Working style
- Start each phase in a fresh session with `/clear`. Use `/compact` at natural breaks.
- Work in this order: explore, plan, code, verify. The verify skill is the check.
- Default model is Opus 5.5 at medium effort. Switch to Sonnet 5.5 with `/model` for routine edits.
.claude/agents/test-runner.md---
name: test-runner
description: Runs the iOS unit tests for the app and returns a short failure summary. Use when tests need running without loading full xcodebuild logs into the main context.
model: haiku
tools: Bash, Read
maxTurns: 8
---
You run the the app unit tests and report only what failed.
## Steps
1. Run the test command from CLAUDE.md. If CLAUDE.md is unavailable, use:
`xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16' > /tmp/whiteboard-test.log 2>&1; echo "exit=$?"`
2. If the exit code is 0, reply `PASS: all tests passed` and stop.
3. If the exit code is not 0, search the log for failures only:
`grep -n -E "error:|failed|XCTAssert" /tmp/whiteboard-test.log | head -n 60`
4. Read only the lines around each failure you found. Do not read the whole log.
5. If the build failed before tests ran, report the first `error:` line and stop.
## Output format
For each failing test, return one line:
`<TestClass>.<testName> | <file>:<line> | <one-line cause>`
Then a final line: `FAIL: <count> test(s) failed`.
## Rules
- Never paste full logs, stack traces, or build output.
- Keep each cause to one line, quoting the assertion message when there is one.
- If you cannot determine a cause, write `cause unclear, see /tmp/whiteboard-test.log`.
- Do not edit files, change settings, or retry the test run more than once.
.claude/settings.json{
"model": "opus",
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(xcodebuild build:*)",
"Bash(xcodebuild test:*)",
"Bash(swift-format lint:*)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(ls:*)",
"Bash(cat:*)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)",
"Bash(git reset --hard:*)",
"Bash(xcrun simctl erase:*)",
"Bash(fastlane match:*)",
"Bash(rm -rf ~/Library/Developer/Xcode/Archives:*)",
"Bash(cp:* ~/Library/Developer/Xcode/Archives/*)",
"Bash(mv:* ~/Library/Developer/Xcode/Archives/*)"
]
}
}
.claude/skills/parse-whiteboard/SKILL.md---
name: parse-whiteboard
description: Use when changing parser rules or adding whiteboard fixtures for the the app app, so parser tests stay tied to real OCR text.
---
# Parse whiteboard
Use this skill to tune the text parser against fixture photos and their expected output. The parser takes recognized lines as `[String]` and returns a `WorkoutPlan`. Parser tests never call Vision.
## Fixture layout
Each fixture has three files that share a basename:
- `Tests/Fixtures/images/<name>.jpg` - the sample photo
- `Tests/Fixtures/ocr/<name>.txt` - the raw OCR lines, one per line, in the order Vision returned them
- `Tests/Fixtures/expected/<name>.json` - the expected `WorkoutPlan`
Use the same basename for all three. Example: `tabata_01.jpg`, `tabata_01.txt`, `tabata_01.json`.
## Adding a fixture
1. Copy the photo into `Tests/Fixtures/images/`.
2. Write the raw OCR text into `Tests/Fixtures/ocr/` with the same basename. Copy the lines as recognized. Do not clean them up, because the parser has to handle misspellings and missing rest lines.
3. Write the expected plan JSON into `Tests/Fixtures/expected/`. Use the `WorkoutPlan` shape from the model. Include the expected round count and any warnings the parser should produce.
4. Check the photo for identifying details. Remove or skip it if a gym member or face is visible. Keep only the whiteboard text.
5. Run the parser tests. Use the test class name from `Tests/`, for example:
```
xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16' -only-testing:WhiteboardTimerTests/ParserTests
```
Adjust the `-only-testing` path to match the actual parser test class.
## Changing parser rules
- Make the smallest rule change that fixes the failing fixture.
- After each change, run the full parser test class, not only the new fixture. A rule that fixes one board can break another.
- Keep the goal in PLAN.md Phase 2 in mind: at least 7 of 10 fixtures must match their expected JSON.
- If a fixture needs a rule that would make the parser guess wildly, return a warning instead of forcing a match.
## Expected JSON rules
- Never edit an expected JSON file to make a test pass without stating why in the reply.
- When you do change one, name the fixture, the old value, the new value, and the reason. Example: "tabata_03.json: rounds 4 to 5. The board shows a fifth round that OCR split across two lines; confirmed against the photo."
- If the photo and the expected file disagree, trust the photo and report the disagreement to the user. Do not silently pick either side.
## Privacy
- Keep sample photos free of identifying gym members or faces.
- Do not commit photos that show names, phone numbers, or gym logos tied to a person.
- If you are unsure whether a photo is safe, ask the user before adding it.
## Reporting
End with:
- Fixtures added or changed, by basename
- Parser rules changed, with a one-line reason each
- Test result from the parser test class
- Any expected JSON edit and its stated reason
.claude/skills/verify/SKILL.md---
name: verify
description: Use after any Swift change to build, run tests, and lint before reporting done on the the app iOS app.
---
# Verify
Prove a change works before reporting it done. Run the steps in order. Stop at the first failing step that blocks the rest, but still report every step's status.
Use the scheme and destination from CLAUDE.md:
- Scheme: `the app`
- Destination: `platform=iOS Simulator,name=iPhone 16`
## Step 1: Build
Run:
```
xcodebuild build -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'
```
- If the build fails, stop here.
- Report the first `error:` line with its file and line number. Ignore later errors, which are often follow-on noise.
- Do not attempt fixes beyond that first error in this step.
## Step 2: Unit tests
Run:
```
xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'
```
- Do not paste full logs into the reply.
- Report only failing test names, with each assertion message and its file and line.
- If the output has no failures, report PASS with the total test count.
- If the build passed but no tests ran, report FAIL and say so. A green run with zero tests does not count as verified.
## Step 3: Lint
Check whether `swift-format` is installed:
```
command -v swift-format
```
- If installed, run `swift-format lint -r Sources Tests`.
- If not installed, skip this step and say "SKIP: swift-format not installed". Do not install it without asking.
- Report the count of lint findings and the first few file paths. Do not reformat files as part of verification.
## Step 4: Screen check
Run this step only when files under `Views/` changed. Check with `git diff --name-only` against the base branch or the last commit.
- Name each screen affected (for example Scan, Plans, or Timer tab).
- Describe one concrete manual check per screen, such as "Timer tab: countdown decreases once per second and Pause keeps the value after rotation."
- Do not claim the screen works. Simulator UI is checked by a person.
If no files under `Views/` changed, report SKIP for this step.
## Output format
Start with one line per step, in this exact shape:
```
Step 1 build: PASS
Step 2 tests: FAIL (2 failing)
Step 3 lint: SKIP (swift-format not installed)
Step 4 screens: SKIP (no Views changes)
```
Then add a short list of what to fix, one item per failure, with file and line when known. If every step passed or was skipped with a reason, write "Nothing to fix."
Keep the report short. Do not restate passing output.
INSTALL.md# Setup: the app
This is a solo setup for an iOS app built with Claude Code. Allow about 45 minutes the first time.
## Prerequisites
- A Mac with the latest Xcode from the Mac App Store. Open Xcode once to accept the license and install the iOS simulator runtime.
- An Apple ID signed into Xcode (Xcode > Settings > Accounts).
- Claude Code installed. Run `claude` once and finish the login.
- Git. The Xcode Command Line Tools include it.
## 1. Create the Xcode project
1. In Xcode, choose File > New > Project > iOS > App.
2. Product Name: the app. Interface: SwiftUI. Language: Swift.
3. Check "Include Tests".
4. Bundle Identifier: `com.example.the-app`. Leave it as is.
5. In the target's General tab, set Minimum Deployments to iOS 17.
6. Save the project in the folder you will use as the repo root.
## 2. Initialize git
```
cd /path/to/the app
git init
```
## 3. Copy the setup files
Copy these into the folder that contains `the app.xcodeproj`:
- CLAUDE.md
- SPEC.md
- PLAN.md
- REVIEW.md
- The `.claude/` folder
## 4. Set your signing team
In Xcode, select the the app target, open Signing & Capabilities, and choose your team there.
Do not type your team ID into any file in the repo. The committed files use `TEAM_ID_PLACEHOLDER`, and it stays that way.
## 5. Start Claude Code and check the defaults
From the project root, run:
```
claude
```
- Type `/memory` to confirm CLAUDE.md loaded.
- Type `/model` to confirm Opus 5.5 is selected. Type `/effort` to confirm medium.
Opus 5.5 is the default because the parser and timer design need careful reasoning. For routine edits such as renames, layout tweaks, and formatting, switch to Sonnet 5.5 with `/model` to save tokens. Switching models clears the prompt cache, so do it between phases or tasks, not in the middle of one. Changing effort with `/effort` does not clear the cache on these models, so adjust effort freely.
The shared `.claude/settings.json` sets Claude to accept file edits without prompts. Builds, tests, and read-only git and file commands are pre-approved. Destructive commands are denied.
## 6. Install the plugins
Inside Claude Code, run:
```
/plugin install code-review@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
```
Skip `frontend-design` for now. Install it when Phase 5 starts, if you want SwiftUI layout help for the timer screen.
## 7. Confirm the baseline is green
Run `/verify` once. The build and test steps should both pass against the Xcode template.
If the build fails, fix it in Xcode before starting Phase 1. Do not start Phase 1 on a red baseline.
## 8. Commit the setup
```
git add -A
git commit -m "Add Claude Code project setup"
```
## 9. Start Phase 1
Open PLAN.md and copy the Phase 1 `/goal` block. In Claude Code, run `/clear`, then paste the block to begin.
## How to work through the phases
- `/goal` sets a completion condition, and Claude keeps working until a small model confirms it is met from the conversation so far. Run `/goal clear` to stop it early.
- Run `/clear` before each new phase so it starts with a clean context.
- Run `/compact` at natural breaks inside a phase to shrink the context without losing the thread.
- Run `/usage` after each phase to see where tokens went.
- Each phase's `/goal` block has a turn bound. If a phase stops at the bound, read its report before you restart it.
- Keep parser and timer work separate from view work where you can. The architecture rules in CLAUDE.md make that split clear.
PLAN.md# Build plan
Start each phase in a fresh session. Run `/clear` before pasting the phase's /goal block. Use `/compact` at natural breaks inside a phase.
Each phase ends with a /goal block. Paste it as one message. Run `/goal clear` if a phase stops early.
## Phase 1: Project skeleton
Create the Xcode project shell, SwiftUI tabs, and the test target. Confirm the verify skill passes.
Done when:
- The app builds for iPhone 16 simulator.
- The app shows three tabs: Scan, Plans, Timer. The tabs are empty.
- The unit test target has one passing placeholder test.
```
/goal Phase 1 complete when: an Xcode project named the app builds for the iPhone 16 simulator, a SwiftUI app shell shows three empty tabs (Scan, Plans, Timer), a unit test target exists with one passing placeholder test, and `xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'` exits 0. Check: run the verify skill and report its PASS/FAIL lines. Constraints: do not edit SPEC.md; add no third-party packages; do not change the bundle id from com.example.the-app; do not commit signing secrets. Stop after 40 turns and report the state of each check.
```
## Phase 2: Parser
Build the WorkoutPlan model and the pure text parser. Use fixtures for tests. Do not import UIKit or SwiftUI in this code.
Done when:
- All parser tests pass.
- At least 7 of the 10 fixtures produce output that matches their expected JSON.
```
/goal Phase 2 complete when: WorkoutPlan model and a pure Swift parser exist under Parsing/, the parser takes [String] and returns a WorkoutPlan with warnings, Tests/Fixtures contains 10 OCR text files with matching expected plan JSON, all parser tests pass under `xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'`, and at least 7 of 10 fixtures match their expected JSON. Parser code must not import UIKit or SwiftUI and tests must not call Vision. Constraints: do not edit SPEC.md; do not edit an expected JSON to make a test pass without stating why in the reply; add no third-party packages. Stop after 40 turns and report the test counts and the fixture match count.
```
## Phase 3: Timer state machine
Build the timer as a pure state machine driven by an injected clock protocol. Tests must advance time without sleeping.
Done when:
- Unit tests cover start, pause, skip, round change, and completion.
- No timer test uses real sleep.
```
/goal Phase 3 complete when: a timer state machine exists under Timer/ with no UI imports, it is driven by a Clock protocol, unit tests cover start, pause, skip, round change, and completion, no test uses sleep or real time, and `xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'` exits 0. Constraints: do not edit SPEC.md; add no third-party packages; do not use force unwraps or try! outside tests. Stop after 40 turns and report the test results.
```
## Phase 4: Camera, OCR, and preview
Wrap Vision text recognition and connect camera and photo picker input to the parser. Sort observations by bounding box before parsing.
Done when:
- A sample image from Tests/Fixtures runs through the flow in the simulator.
- The review screen shows the editable OCR text and the parsed rounds preview.
```
/goal Phase 4 complete when: a VNRecognizeTextRequest wrapper exists under Camera/, observations are sorted by bounding box y then x before parsing, camera and photo picker flows feed the parser, and the review screen shows editable OCR text plus a parsed rounds preview. `xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'` exits 0. Manual check: launch the app in the simulator, choose a bundled sample image through the Photos picker, and confirm the preview lists parsed rounds. Constraints: do not edit SPEC.md; add no third-party packages; Info.plist must contain the two privacy strings from SPEC.md. Stop after 40 turns and report the test result and what the manual check showed.
```
## Phase 5: Timer UI, audio, haptics, and saved plans
Build the timer screen, background audio, haptics at round change, and local saved plans. Run the manual QA checklist in REVIEW.md.
Done when:
- The timer screen runs a saved plan.
- The timer keeps counting through backgrounding.
- Saved plans survive an app relaunch.
- The REVIEW.md manual checklist passes in the simulator.
```
/goal Phase 5 complete when: the Timer tab runs a selected plan with start, pause, skip, and next-round controls; beeps and haptics fire at each round change; the background audio session keeps the timer counting after backgrounding; plans are saved locally and still listed after the app relaunches; and every item in the manual checklist in REVIEW.md passes in the simulator. `xcodebuild test -scheme the app -destination 'platform=iOS Simulator,name=iPhone 16'` exits 0. Constraints: do not edit SPEC.md; add no third-party packages; no network calls. Stop after 40 turns and report each checklist item as PASS or FAIL.
```
## Phase 6: Polish and release check
Add accessibility labels, move user-visible strings into a String Catalog, and draft App Store metadata.
Done when:
- Every control has an accessibility label.
- User-visible strings come from a String Catalog.
- A release archive builds with no warnings.
```
/goal Phase 6 complete when: every interactive control has an accessibility label, user-visible strings live in a String Catalog, a draft App Store metadata file exists at AppStore/metadata.md with no real account data, and `xcodebuild archive -scheme the app -destination 'generic/platform=iOS' -archivePath build/the app.xcarchive CODE_SIGNING_ALLOWED=NO` finishes with zero warnings. Constraints: do not edit SPEC.md; add no third-party packages; do not write archives outside build/; do not commit signing certificates, provisioning profiles, or App Store Connect keys. Stop after 40 turns and report the archive warning count.
```
REVIEW.md# Review rules
Apply these rules to your own diffs before you report a phase done, and to `/code-review` output. Fix blocking items before a phase closes. Fix warnings or name them in your reply.
## Blocking
- Code in `Parsing/` or `Timer/` imports UIKit or SwiftUI. Both stay pure Swift.
- A new package dependency is added without a matching entry in SPEC.md.
- Camera or photo access is used without `NSCameraUsageDescription` or `NSPhotoLibraryUsageDescription` in Info.plist. The strings must be plain text that says why the app needs access.
- `!` force unwraps or `try!` appear outside `Tests/`.
- A timer test uses real sleep (`sleep`, `Task.sleep`, or wall-clock waits) instead of the injected clock.
## Warnings
- Vision observations are used without sorting by bounding box (y, then x).
- User-visible strings are hard-coded instead of living in a String Catalog.
## Manual QA checklist (device-free)
Run these in the iPhone 16 simulator before closing Phase 5 and again before Phase 6 ends.
- [ ] A bundled sample photo parses into a plan with the expected round count.
- [ ] The timer counts down accurately across backgrounding. Lock the screen for 30 seconds; remaining time matches elapsed time.
- [ ] Skip moves to the next interval and the display updates at once.
- [ ] Pause persists after the device rotates.
## Notes for Claude
- `/code-review` reads CLAUDE.md but not this file. CLAUDE.md imports this file with `@REVIEW.md`, so these rules apply to both your self-review and `/code-review`.
- In your reply, list which rules you checked and which findings you fixed.
SPEC.md# Spec: Whiteboard timer
## Purpose
A solo iPhone app for gym use. The user photographs a whiteboard workout, reviews the text the app read from it, and runs a timer through the rounds.
## v1 user flow
1. Open the Scan tab.
2. Take a photo with the camera, or pick one from the Photos library.
3. Review the recognized text in an editable text field.
4. Confirm. The app parses the text into a plan.
5. See the parsed rounds list, with warnings for anything unclear.
6. Start the timer from the plan.
7. Save the plan to the Plans tab for later.
## Supported workout shapes (v1)
- N rounds of listed exercises, each with work seconds or a rep count.
- Optional rest between rounds, in seconds.
- AMRAP and EMOM labels are recognized as text and shown in the plan. They do not change timing in v1.
- Anything else becomes a warning on the review screen. The app never crashes on unexpected text.
## Timer features
- Start, pause, skip current interval, and jump to next round.
- Total elapsed time for the session.
- Beep and haptic at each round change.
- Keeps counting with the screen locked, using a background audio session.
- Pause state survives device rotation.
## Non-goals (v1)
- No accounts, sign-in, or cloud sync.
- No social features.
- No video capture or playback.
- No Apple Watch app.
- No HealthKit reads or writes.
- No network calls of any kind.
## Data
- Saved plans are stored locally with SwiftData or Codable JSON. Pick one in Phase 5 and record the choice here.
- Photos are not stored after OCR. Only the edited text and the parsed plan are kept.
## Privacy
- Photos are processed on device with Vision. Nothing leaves the phone.
- Info.plist must include plain-text usage strings:
- `NSCameraUsageDescription`: "Takes a photo of your whiteboard so the app can read the workout."
- `NSPhotoLibraryUsageDescription`: "Lets you choose a whiteboard photo to read the workout from."
- Sample photos in Tests/Fixtures must not show identifiable gym members or faces.
## Dependencies
- Approved third-party packages: none.
- Any new package needs an entry in this section before it is added. REVIEW.md blocks it otherwise.
## Acceptance criteria
- Tests/Fixtures/images holds 10 sample whiteboard photos.
- Each photo has matching raw OCR text in Tests/Fixtures/ocr and an expected plan JSON in Tests/Fixtures/expected.
- At least 7 of the 10 photos parse into a plan with the correct round count, after manual review of the expected files.
- All parser unit tests pass.
- All timer unit tests pass.
- The manual QA checklist in REVIEW.md passes in the simulator.
- A release archive builds with no warnings.
## Open questions for the user
1. Minimum iOS version. The current plan assumes iOS 17.
2. Are reps timed? For example, does 10 reps take a set time, or is it untimed?
3. Should the app show kg and lb? If yes, which is the default?