After building the macOS tracker app entirely through Claude Code prompts, I did the same thing with the iOS companion app. The xeve iOS app tracks health metrics via HealthKit, logs location with geofencing, monitors heart rate over BLE, syncs offline via SwiftData, and has four widget types including lock screen widgets. It was built in a single morning session and deployed to TestFlight the same day.
This post covers every prompt, every issue, and the specific techniques that made it work. If you read the macOS post, this follows the same format — but the iOS app had its own set of challenges around HealthKit permissions, background tasks, widget data sharing, and App Store distribution.
The Starting Point
Unlike the macOS app which started from scratch, the iOS app had context. The macOS app was already running, the Supabase backend had all the tables and RPC functions, and the design system was well-established. The iOS app's job was to be a companion — not a duplicate of the Mac tracker, but a complement that adds health data, location awareness, and a quick-glance dashboard.
The initial prompt was comprehensive:
Build an iOS companion app at apps/ios/ with these features:
1. HealthKit integration — read steps, active energy, heart rate, sleep,
distance, flights climbed. Query today's totals for display and
collect raw samples for Supabase sync.
2. Location tracking — significant location changes in background,
home/work geofencing (200m radius), manual check-ins with named places
3. BLE heart rate — same as macOS app, scan for 180D service, parse 2A37
characteristic, auto-connect to preferred device
4. SwiftData offline sync — all health samples and location logs stored
locally with isSynced flag, background task syncs every 15 minutes
5. Widgets — productivity score (small), today overview (medium),
lock screen (3 variants: circular gauge, inline text, rectangular)
6. Tab bar with 5 tabs: Today, Activity, Life, Analysis, Settings
7. Google OAuth with xeve://auth/callback scheme
8. Same Supabase backend, same design system (dark bg, monospace,
accent orange, 2px border radius max)
Use project.yml for XcodeGen. Include a build-testflight.sh script.
Claude Code generated the entire app structure in one pass. This was significantly more complex than the macOS app's initial generation — 20+ Swift files across the main app target and the widget extension target, plus shared code for the App Group data bridge.
Project Structure Decisions
Several architectural decisions were baked into the initial prompt based on what I learned from the macOS app:
XcodeGen Again
Just like the macOS app, the iOS app uses project.yml for XcodeGen. This was non-negotiable — I had already proven that Claude Code cannot reliably edit .xcodeproj files directly. The YAML file defines both targets (the main app and the widget extension), their entitlements, Info.plist values, SPM dependencies, and build settings.
Two Targets in One Project
The iOS app has two targets: xeve (the main app) and xeve-widgets (the WidgetKit extension). In project.yml, this looks like:
targets:
xeve:
type: application
platform: iOS
# ... main app config
xeve-widgets:
type: appExtension
platform: iOS
# ... widget config
Claude Code set up the dependency between them correctly — the widget extension is embedded in the main app bundle and shares an App Group for data communication.
SwiftData Over Core Data
I explicitly chose SwiftData over Core Data for offline persistence. SwiftData is newer, simpler, and integrates cleanly with SwiftUI. The trade-off is that it requires iOS 17+, which I was fine with since this is a new app.
The @Model declarations are clean:
@Model
final class HealthSample {
var metricType: String
var value: Double
var unit: String
var startTime: Date
var endTime: Date
var source: String?
var isSynced: Bool
}
The isSynced flag is the key to offline-first sync. Data is always saved locally first, then uploaded to Supabase in background batches. If the upload fails, the records stay local and retry on the next sync.
HealthKit Integration
HealthKit was the primary reason for building the iOS app. The macOS app has no access to health data — that is an iPhone/Apple Watch domain.
The HealthKit manager should read 6 metric types: steps, active energy,
heart rate, sleep, distance, and flights climbed. Use HKStatisticsQuery
for today's cumulative totals (displayed in the UI) and HKSampleQuery
for raw samples (synced to Supabase). Sleep analysis should filter out
"inBed" and only count actual asleep time.
Issue: HealthKit Authorization
The first issue was that HealthKit authorization requires specific entitlements and Info.plist keys. Claude Code added the HealthKit entitlement to the main app target and the usage description strings to Info.plist, but the entitlements file also needed the explicit capability:
HealthKit authorization is failing with "not available on this device".
Make sure the HealthKit entitlement is in the .entitlements file and
the HealthKit capability is declared in project.yml.
The fix was adding com.apple.developer.healthkit to the entitlements plist and HealthKit to the capabilities section of project.yml. After regenerating the Xcode project, the authorization prompt appeared correctly.
Issue: Async HealthKit Queries
HealthKit's query APIs are callback-based, not async/await. Claude Code wrapped them in withCheckedContinuation:
func fetchSteps() async -> Double {
await withCheckedContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: HKQuantityType(.stepCount),
quantitySamplePredicate: todayPredicate
) { _, result, _ in
let steps = result?.sumQuantity()?
.doubleValue(for: .count()) ?? 0
continuation.resume(returning: steps)
}
healthStore.execute(query)
}
}
This pattern was used for all 6 metric types. The one subtle bug was with sleep analysis — the initial implementation counted all HKCategoryValueSleepAnalysis samples, including .inBed which represents time in bed but not necessarily asleep. The fix was filtering for .asleepCore, .asleepDeep, .asleepREM, and .asleepUnspecified only.
Sleep hours are showing 10+ hours which is wrong. The query is counting
inBed time as sleep. Filter to only count actual asleep categories
(asleepCore, asleepDeep, asleepREM, asleepUnspecified).
Location Tracking
Location tracking in the iOS app has three modes: significant location changes (background), home/work geofencing, and manual check-ins.
Implement location tracking with:
1. Significant location changes for background monitoring
2. Home and work places saved by the user (store lat/lon + name in
UserDefaults)
3. Geofencing with 200m radius to detect home/work arrival/departure
4. Manual check-in button that saves current location with a user-typed
name
5. LocationLog SwiftData model with isSynced flag
6. Reverse geocoding for automatic place names
Issue: Background Location Permissions
iOS location permissions are tiered: "When In Use" and "Always". Background location monitoring requires "Always" permission, but Apple's guidelines require requesting "When In Use" first and then upgrading to "Always" only when the user understands why.
Location tracking stops when the app goes to background. We need
"Always" authorization for significant location changes. Add the
requestAlwaysAuthorization flow — first request WhenInUse, then upgrade
to Always after the user has seen the feature working.
Claude Code implemented a two-step authorization flow: on first launch, request "When In Use". After the user saves a home or work location (demonstrating they want background tracking), upgrade to "Always".
Issue: Geofencing with CLCircularRegion
The geofencing implementation uses CLCircularRegion with a 200m radius around saved home/work coordinates. When the user enters or exits the region, the app updates the currentPlace state and logs a LocationLog entry.
The subtle issue was that startMonitoring(for:) can only monitor 20 regions simultaneously on iOS. Since we only use 2 (home and work), this was not a problem, but Claude Code correctly documented the limit in the code comments for future reference.
The Widget System
Widgets were the most architecturally complex part because they run in a separate process with no access to the main app's state.
Build 4 widget types:
1. Productivity (small) — large score number, stacked duration bar,
"X TRACKED" label
2. Today Overview (medium) — left: score + bar, right: 6 metric grid
(location, coding, top app, switches, heart rate, sleep)
3. Lock Screen (3 variants):
- Circular: gauge showing score 0-100
- Inline: "PLACE · SCORE% · Xh YYm CODE"
- Rectangular: title, score, coding time, progress bar
Data bridge via App Group UserDefaults. Main app writes SharedData,
widgets read it. Timeline refresh every 15 minutes.
The SharedData Bridge
This was the key architectural decision for widgets. The main app and widget extension share data through an App Group container (group.com.xeve.ios). Claude Code created a SharedData.swift file in a Shared/ directory that both targets include:
struct SharedData {
private static let defaults = UserDefaults(
suiteName: "group.com.xeve.ios"
)!
static var productivityScore: Int {
defaults.integer(forKey: "productivityScore")
}
static func update(
productivityScore: Int,
codingMinutes: Int,
topApp: String,
// ... other fields
) {
defaults.set(productivityScore, forKey: "productivityScore")
defaults.set(codingMinutes, forKey: "codingMinutes")
// ...
WidgetCenter.shared.reloadAllTimelines()
}
}
The main app calls SharedData.update() whenever it syncs new data. Widgets call SharedData.productivityScore etc. in their timeline provider. The WidgetCenter.shared.reloadAllTimelines() call tells iOS to refresh all widget displays.
Issue: Widgets Could Not Find Shared Code
The first build failed because the widget extension target could not find SharedData.swift. In XcodeGen, you need to explicitly include source files in each target:
The widget target can't find SharedData. The Shared/ directory needs to
be included in both the main app and widget extension sources in
project.yml.
Claude Code updated project.yml to include Shared/** in both targets' source paths.
Issue: Color Extension in Widgets
Widgets cannot use asset catalog colors from the main app. They need inline color definitions. Claude Code created a WidgetColorExtension.swift with Color(hex:) initializer that all widgets use for the design system colors.
Lock Screen Widget Sizing
Lock screen widgets have three families: .accessoryCircular, .accessoryInline, and .accessoryRectangular. Each has strict size constraints. The circular widget uses a Gauge view for the score. The inline widget packs location, score, and coding time into a single text line. The rectangular widget has 3 lines of content.
The lock screen inline widget text is being truncated. Shorten the
format to "HOME · 72% · 2h 15m CODE" — abbreviate everything.
SwiftData Offline Sync
The sync system was designed to be resilient. Health data and location logs are always saved to SwiftData first, then uploaded to Supabase in background batches.
Implement SyncManager with:
1. BGAppRefreshTask every 15 minutes for light sync
2. BGProcessingTask every 30 minutes for full sync (requires network)
3. Foreground sync on app open
4. Query SwiftData for records where isSynced == false
5. Upload batch to Supabase
6. Mark records as isSynced = true on success
7. On failure, leave records as unsynced (retry next cycle)
8. Never run concurrent syncs (check isSyncing flag)
Issue: ModelContext Threading
SwiftData's ModelContext is not thread-safe. The initial implementation passed the context from the main thread to a background task, which caused crashes. The fix was creating a new ModelContext in the background task from the shared ModelContainer:
The app is crashing when SyncManager accesses ModelContext from a
background thread. SwiftData ModelContext isn't thread-safe. Create a
new context from the container in the background task instead of
sharing the main context.
Issue: Configuring ModelContext for Services
Services need the ModelContext to query and update SwiftData records, but the context is only available after the SwiftUI .modelContainer modifier injects it. Claude Code solved this with a configure(modelContext:) method on each service, called in ContentView.onAppear:
func configure(modelContext: ModelContext) {
syncManager.configure(modelContext: modelContext)
healthKit.configure(modelContext: modelContext)
locationManager.configure(modelContext: modelContext)
}
This deferred initialization pattern ensures services do not try to access SwiftData before the container is ready.
The Nested ObservableObject Problem
This was the most frustrating SwiftUI issue. The AppState object owns multiple services (healthKit, locationManager, heartRate, etc.), each of which is an ObservableObject. But SwiftUI does not automatically propagate objectWillChange from nested ObservableObjects.
What this means in practice: when healthKit.steps updates, AppState.objectWillChange does not fire, and views that observe AppState do not refresh. The heart rate would update but the UI would show the old value.
The UI is not updating when health data changes. I think the nested
ObservableObject problem is happening — AppState owns HealthKitManager
but SwiftUI doesn't propagate objectWillChange from nested objects.
Add Combine sink forwarding for every nested service.
Claude Code added Combine publishers to forward changes:
init() {
healthKit.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}.store(in: &cancellables)
locationManager.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}.store(in: &cancellables)
heartRate.objectWillChange.sink { [weak self] _ in
self?.objectWillChange.send()
}.store(in: &cancellables)
// ... every service
}
This is boilerplate that Swift/SwiftUI should handle automatically, but does not. Every new service added to AppState needs its own .objectWillChange.sink forwarding line. Claude Code documented this requirement in the iOS CLAUDE.md so future prompts include it.
Deploying to TestFlight
Unlike the macOS app which distributes via Sparkle + GitHub Releases, the iOS app goes through Apple's TestFlight system for beta testing and eventually the App Store.
Create a build-testflight.sh script that:
1. Runs xcodegen generate
2. Restores entitlements (xcodegen sometimes resets them)
3. Increments the build number in project.yml
4. Archives via xcodebuild
5. Exports to .ipa using ExportOptions.plist
6. Uploads to App Store Connect via xcrun altool
Also create the ExportOptions.plist for App Store distribution.
Issue: Entitlements Reset
XcodeGen sometimes strips or resets entitlements when regenerating the project. The build script includes a step that copies the known-good entitlements files back into place after xcodegen generate:
The entitlements are getting reset when xcodegen runs. Add a step in
the build script to restore them from the source directory after
project generation.
Issue: Xcode CLI Build Failures
The TestFlight build script uses xcodebuild archive followed by xcodebuild -exportArchive. On certain Xcode versions (particularly Xcode 26 beta), the SPM dependency resolution step fails with a swift-clocks package error. The workaround was to use xcodebuild -resolvePackageDependencies as a separate step before archiving.
The archive is failing with a swift-clocks SPM resolution error.
This is a known Xcode bug. Add a separate package resolution step
before the archive command, and document the fallback of archiving
from the IDE if CLI fails.
The build script now includes a comment documenting that if the CLI build fails on certain Xcode versions, you can archive from the Xcode IDE as a fallback. The export and upload steps still work from the CLI.
Issue: App Store Connect Upload
The upload uses xcrun altool --upload-app which requires an app-specific password (not your Apple ID password). This needs to be generated at appleid.apple.com and stored securely.
The TestFlight upload is failing with auth error. xcrun altool needs
an app-specific password, not the Apple ID password. Update the script
to use APPLE_APP_SPECIFIC_PASSWORD env var.
Features Added After Initial Build
Incognito Mode
Add Incognito mode to the iOS app — same as macOS. When enabled, stop
location tracking and heart rate monitoring. Show a banner in the UI
with a spring animation on toggle. Persist in UserDefaults.
App Icons
The app needs an icon. The macOS app already has one — use the same
design but sized for iOS (1024x1024 for App Store, auto-generated
smaller sizes). The icon should work with the dark background.
Claude Code cannot generate images, so for the app icon, I provided the image file and Claude Code configured the asset catalog to reference it correctly.
Energy Forecast Widgets
Add a new widget type: Energy Forecast (small). Shows today's energy
score out of 100, peak hours window, meeting hours, and a burnout risk
dot if active. Use SharedData bridge for the data. Follow the same
industrial design — monospace, dark bg, accent orange.
Device Registration
Add device registration on app launch. Upsert to the devices table with
device name, platform "ios", OS version, app version, and last_seen_at.
Update last_seen_at on every sync.
Morning Energy Briefing
Add local notifications for a daily energy briefing. User sets the time
in Settings (default 7am). The notification shows today's peak hours and
energy score. Use UNUserNotificationCenter with a repeating daily
trigger. No push server needed — content updates during background sync.
The Full Prompt Catalog
Here is a condensed list of every significant prompt I used, in chronological order. Each of these was a separate Claude Code interaction:
- Initial app generation — the big prompt with all 7 features
- Fix HealthKit entitlement — "not available on this device" error
- Fix sleep counting — filter inBed from actual sleep
- Fix widget shared data — add Shared/ to both targets
- Fix nested ObservableObject — add Combine forwarding
- Fix ModelContext threading — create context per background task
- Fix background location — Always authorization flow
- Fix lock screen widget truncation — abbreviate format
- Add Incognito mode — pause tracking + UI indicator
- Add app icons — configure asset catalog
- Create TestFlight script — archive, export, upload
- Fix entitlements reset — restore after xcodegen
- Fix SPM resolution — separate resolve step
- Fix altool auth — app-specific password
- Add CLAUDE.md — document all patterns for future prompts
- Add energy forecast widget — new small widget type
- Add device registration — upsert to devices table
- Add morning briefing — local notification with daily trigger
That is 18 prompts over the course of a few days. The first one generated 80% of the app. The rest were fixes, features, and deployment.
iOS vs macOS: What Was Different
Permissions Are Harder on iOS
macOS has Accessibility and Automation permissions that are annoying but straightforward. iOS has HealthKit, Location (When In Use vs Always), Bluetooth, Notifications, and potentially FamilyControls (Screen Time API) — each with its own authorization flow, entitlement, Info.plist key, and App Store review implications.
Every permission required at least one round of "it is not working because the entitlement/plist key is missing." Claude Code knows the APIs but sometimes misses the project configuration side.
Background Execution Is Restricted
The macOS app runs a 60-second sync timer that fires reliably. On iOS, background execution is heavily throttled. The app registers BGAppRefreshTask and BGProcessingTask, but iOS decides when to actually run them. The 15-minute and 30-minute intervals are suggestions, not guarantees.
In practice, the most reliable sync trigger is .onAppear — when the user opens the app. Background tasks are a bonus, not a guarantee.
Widgets Are a Separate Target
On macOS, the menu bar is part of the main app process. On iOS, widgets are a separate app extension with their own process, memory limits, and no access to the main app's state. The App Group data bridge is mandatory, and it adds complexity that does not exist on macOS.
Distribution Is More Complex
The macOS app distributes directly via a signed zip file hosted on Supabase Storage. Users download and drag to Applications. On iOS, everything goes through Apple — TestFlight for beta, App Store for production. The build-testflight.sh script handles this, but the upload requires an App Store Connect record, a team provisioning profile, and an app-specific password.
What I Learned for AI-Assisted iOS Development
- HealthKit is well-documented enough for AI. Claude Code generated correct HealthKit queries on the first attempt for 5 out of 6 metric types. Sleep was the exception because the filtering of inBed vs asleep is a common gotcha.
- SwiftData is simpler than Core Data for AI. The
@Modelmacro andModelContextAPI are straightforward enough that Claude Code handles them correctly. Core Data's .xcdatamodel files would have been impossible to generate from prompts. - Widget architecture needs explicit instructions. The App Group data bridge, separate targets in project.yml, and widget timeline providers are non-obvious. Include these in your prompt or your CLAUDE.md.
- Test on a real device early. HealthKit, CoreLocation, and CoreBluetooth do not work in the Simulator. I tested on my iPhone from the first build.
- Document the ObservableObject forwarding. This bit me once and then I added it to CLAUDE.md. Every new developer (human or AI) working on this codebase needs to know about it.
- The CLAUDE.md is your force multiplier. After writing the iOS CLAUDE.md, every subsequent prompt produced code that matched the app's architecture perfectly. Without it, each prompt risks introducing inconsistent patterns.
The Numbers
From initial prompt to TestFlight-ready build: 1 day.
- ~20 Swift source files in the main app target
- 6 Swift files in the widget extension target
- 1 shared data bridge file
- 6 HealthKit metric types tracked
- 4 widget types (small, medium, 3 lock screen variants)
- 3 background task types (app refresh, processing, location)
- 2 build/deploy scripts
- 18 total Claude Code prompts
- 0 lines of Swift written by hand
For Other Claude Code Users
If you are building an iOS app with Claude Code:
- Start with a comprehensive initial prompt. Unlike web development where you might iterate file by file, iOS apps need a coherent project structure from the start. Targets, entitlements, Info.plist keys, and SPM dependencies all need to align. Give Claude Code the full picture upfront.
- Use XcodeGen. Same as macOS — the .xcodeproj is not for text editing.
- List every permission explicitly. HealthKit needs an entitlement, an Info.plist usage string, and a capability in project.yml. Missing any one of these causes a silent failure or a crash. Tell Claude Code exactly which permissions you need.
- Write CLAUDE.md before adding features. Document the AppState pattern, the SwiftData sync pattern, the widget data bridge, and the ObservableObject forwarding requirement. Every future prompt will produce better code because of it.
- Build and test on a real device. The Simulator lacks too many iOS capabilities (HealthKit, Bluetooth, background location, widgets in some cases). Plug in your iPhone and build to it directly.
- Keep the macOS and iOS CLAUDE.md files in sync. Both apps share the same Supabase backend, category system, and design language. The CLAUDE.md files ensure Claude Code maintains consistency across platforms.
- Expect 2-3 fix rounds per feature. The initial generation is usually 80-90% correct. The remaining 10-20% is permissions, entitlements, threading, and edge cases that only surface on a real device. This is normal and fast to fix with the "paste error, fix" loop.
The iOS app is live on TestFlight now. Like the macOS app, every line of Swift was written by Claude Code from natural language prompts. The complete source is in the xeve monorepo at apps/ios/.