Files
Reflect/CLAUDE.md
Trey t b02a497a86 Fix subscription store not loading on TestFlight
The subscription group ID was still set to the old Feels value (21914363).
Updated to the correct Reflect group ID (21951685) from App Store Connect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:10:32 -06:00

471 lines
24 KiB
Markdown

# Reflect
iOS mood tracking app. Users rate their day on a 5-point scale (Horrible to Great) and view patterns via Day, Month, Year, and Insights views. Includes watchOS companion, widgets, Live Activities, and AI-powered insights.
## Build & Run Commands
```bash
# Build
xcodebuild -project Reflect.xcodeproj -scheme "Reflect (iOS)" -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
# Run all tests
xcodebuild -project Reflect.xcodeproj -scheme "Reflect (iOS)" -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test
# Run a single test suite
xcodebuild -project Reflect.xcodeproj -scheme "Reflect (iOS)" -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:"Tests iOS/Tests_iOS" test
# Run a single test
xcodebuild -project Reflect.xcodeproj -scheme "Reflect (iOS)" -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:"Tests iOS/Tests_iOS/testDatesBetween" test
```
## Architecture Overview
- **Pattern**: MVVM with SwiftUI
- **Language/Framework**: Swift / SwiftUI
- **Data**: SwiftData with CloudKit sync (migrated from Core Data)
- **Test Framework**: XCTest (minimal coverage currently)
### Layers
1. **Models** (`Shared/Models/`): Data types and domain logic
- `MoodEntryModel` — SwiftData `@Model` for mood entries
- `Mood` — Enum for mood values (horrible/bad/average/good/great/missing/placeholder)
- `EntryType` — Enum for entry source (listView/widget/watch/shortcut/siri/controlCenter/liveActivity/etc.)
- Customization protocols: `MoodTintable`, `MoodImagable`, `PersonalityPackable`, `Themeable`
2. **Persistence** (`Shared/Persisence/`): SwiftData operations (note: directory has a typo — "Persisence" not "Persistence")
- `DataController` — Singleton `@MainActor` data access layer
- `DataControllerGET` — Read/fetch operations
- `DataControllerADD` — Create operations
- `DataControllerUPDATE` — Update operations
- `DataControllerDELETE` — Delete operations
- `DataControllerHelper` — Utility methods
- `DataControllerProtocol` — Protocol definitions for testability
- `SharedModelContainer` — Factory for `ModelContainer` (shared App Group storage)
- `ExtensionDataProvider` — Data provider for widget and watch extensions
3. **ViewModels** (colocated with views):
- `DayViewViewModel` — Day/Month grid data, mood logging
- `YearViewModel` — Year view filtering and chart data
- `InsightsViewModel` — AI-powered insights via Apple Foundation Models
- `IconViewModel` — Custom app icon selection
- `CustomWidgetStateViewModel` — Widget customization state
4. **Views** (`Shared/Views/`): SwiftUI views organized by feature
- `DayView/`, `MonthView/`, `YearView/`, `InsightsView/`
- `SettingsView/`, `CustomizeView/`, `CustomWidget/`
- `Sharing/`, `SharingTemplates/`
5. **Services** (`Shared/Services/` and `Shared/`): Business logic singletons
- `MoodLogger` — Centralized mood logging with side effects
- `IAPManager` — StoreKit 2 subscriptions
- `AnalyticsManager` — PostHog analytics
- `HealthKitManager` — HealthKit mood sync
- `BiometricAuthManager` — Face ID / Touch ID
- `PhotoManager` — Photo attachment handling
- `WatchConnectivityManager` — Watch-phone communication
- `LiveActivityScheduler` — Live Activity lifecycle
- `ReviewRequestManager` — App Store review prompts
- `FoundationModelsInsightService` — Apple Foundation Models AI
### Key Components
| Component | Type | Responsibility |
|-----------|------|----------------|
| `DataController.shared` | @MainActor singleton | All SwiftData CRUD — single source of truth for data access |
| `MoodLogger.shared` | @MainActor singleton | Centralized mood logging with all side effects (HealthKit, streak, widget, watch, Live Activity) |
| `IAPManager.shared` | @MainActor ObservableObject | StoreKit 2 subscription state, trial tracking, paywall gating |
| `AnalyticsManager.shared` | @MainActor singleton | PostHog event tracking — all analytics go through this |
| `ExtensionDataProvider.shared` | @MainActor singleton | Widget/Watch data access via App Group container |
| `HealthKitManager.shared` | ObservableObject | HealthKit read/write for mood data |
| `WatchConnectivityManager.shared` | ObservableObject (NSObject) | WCSession for phone-watch UI updates |
| `LiveActivityScheduler.shared` | ObservableObject | Manages mood streak Live Activity start/stop timing |
### Data Flow
```
User taps mood → DayViewViewModel.add()
MoodLogger.shared.logMood() ← ALL mood entry points use this
DataController.shared.add() ← SwiftData insert + save
Side effects (all in MoodLogger):
→ HealthKit sync (if enabled + subscribed)
→ Streak calculation
→ Live Activity update
→ Widget reload (WidgetCenter.shared.reloadAllTimelines())
→ Watch notification (WatchConnectivityManager)
→ TipKit parameter update
→ Analytics event
Widget/Watch → ExtensionDataProvider.shared ← Separate container via App Group
MoodLogger.applySideEffects() ← Catch-up when app opens
CloudKit ←→ DataController.container ← Automatic sync via SwiftData CloudKit integration
```
## Data Access Rules
**Source of Truth**: `DataController.shared` — all data reads and writes go through this singleton.
### Correct Usage
```swift
// CORRECT Log a mood through MoodLogger (handles all side effects)
MoodLogger.shared.logMood(.great, for: Date(), entryType: .listView)
// CORRECT Read data through DataController
let entry = DataController.shared.getEntry(byDate: date)
let entries = DataController.shared.getData(startDate: start, endDate: end, includedDays: [1,2,3,4,5,6,7])
// CORRECT Track analytics through AnalyticsManager
AnalyticsManager.shared.track(.moodLogged(mood: mood.rawValue, entryType: "listView"))
```
### Wrong Usage
```swift
// WRONG Never insert into modelContext directly (bypasses side effects and analytics)
let entry = MoodEntryModel(forDate: date, mood: .great, entryType: .listView)
modelContext.insert(entry)
// WRONG Never call PostHogSDK directly (bypasses opt-out checks and config)
PostHogSDK.shared.capture("mood_logged")
// WRONG Never create a separate ModelContainer (breaks shared state)
let container = try ModelContainer(for: MoodEntryModel.self)
```
### Allowed Exceptions
- `DataControllerADD.swift` — Directly inserts into `modelContext` (this IS the data layer)
- `ExtensionDataProvider` — Creates its own `ModelContainer` for widget/watch (can't share process)
- Widget extensions — May call `DataController.add()` directly when `MoodLogger` isn't available
## Architecture Rules
- All mood logging MUST go through `MoodLogger.shared.logMood()`. This ensures HealthKit sync, streak calculation, widget refresh, watch notification, and analytics all fire.
- All data reads MUST go through `DataController.shared` methods (`getEntry`, `getData`, `splitIntoYearMonth`). Views MUST NOT use `@Query` or direct `modelContext` access.
- All analytics MUST go through `AnalyticsManager.shared`. NEVER call `PostHogSDK` directly.
- Any code that modifies mood data MUST call `WidgetCenter.shared.reloadAllTimelines()` (handled automatically by `MoodLogger`).
- NEVER access `DataController.shared` from a background thread — it is `@MainActor` isolated.
- NEVER hardcode App Group identifiers — use `Constants.groupShareId` / `Constants.groupShareIdDebug` or `SharedModelContainer.appGroupID`.
- NEVER hardcode CloudKit container IDs — use `SharedModelContainer.cloudKitContainerID`.
- New SwiftData fetch operations MUST be added to `DataControllerGET.swift`.
- New create operations MUST be added to `DataControllerADD.swift`.
- New update operations MUST be added to `DataControllerUPDATE.swift`.
- New delete operations MUST be added to `DataControllerDELETE.swift`.
- ViewModels MUST be `@MainActor class` conforming to `ObservableObject`.
- ALWAYS use `@StateObject` (not `@ObservedObject`) when a view owns its ViewModel.
- NEVER bypass `IAPManager` subscription checks — use `IAPManager.shared.shouldShowPaywall` to gate premium features.
## Mutation / Write Patterns
```
Create: MoodLogger.shared.logMood(mood, for: date, entryType: type)
→ DataController.shared.add(mood:forDate:entryType:)
→ Deletes existing entries for that date first (prevents duplicates)
→ Inserts new MoodEntryModel
→ saveAndRunDataListeners()
Update: DataController.shared.update(entryDate:withMood:)
DataController.shared.updateNotes(forDate:notes:)
DataController.shared.updatePhoto(forDate:photoID:)
Delete: DataController.shared.clearDB()
DataController.shared.deleteLast(numberOfEntries:)
Fill: DataController.shared.fillInMissingDates()
→ Called by BGTask to backfill missing days with .missing entries
```
### Correct Mutation
```swift
// CORRECT Create a mood entry (full flow with side effects)
MoodLogger.shared.logMood(.good, for: selectedDate, entryType: .listView)
// CORRECT Update notes on an existing entry
DataController.shared.updateNotes(forDate: date, notes: "Had a great day")
DataController.shared.saveAndRunDataListeners()
```
### Wrong Mutation
```swift
// WRONG Calling DataController.add() directly skips side effects
DataController.shared.add(mood: .good, forDate: date, entryType: .listView)
// Missing: HealthKit sync, streak calc, widget reload, watch notify, analytics
```
## Concurrency Patterns
- `DataController` is `@MainActor final class` — all SwiftData operations run on main actor.
- `MoodLogger` is `@MainActor final class` — mood logging and side effects run on main actor.
- `AnalyticsManager` is `@MainActor final class` — analytics calls run on main actor.
- `IAPManager` is `@MainActor class ObservableObject` — StoreKit state is main-actor bound.
- `ExtensionDataProvider` is `@MainActor final class` — extension data access on main actor.
- `HealthKitManager` is `class ObservableObject` (NOT @MainActor) — HealthKit calls may be async.
- `WatchConnectivityManager` is `NSObject, ObservableObject` — WCSession delegate callbacks.
- All ViewModels (`DayViewViewModel`, `YearViewModel`, `InsightsViewModel`) are `@MainActor class ObservableObject`.
- Background work (HealthKit sync) MUST use `Task { }` from `@MainActor` context.
- CloudKit sync happens automatically via SwiftData's built-in CloudKit integration — no manual threading.
- `BGTask.swift` runs `fillInMissingDates()` which accesses `DataController.shared` on the main actor.
### Swift 6 / Sendable Notes
- `Color` gets `@retroactive Codable` and `@retroactive RawRepresentable` conformance in `Color+Codable.swift`.
- `Date` gets `@retroactive RawRepresentable` conformance in `Date+Extensions.swift`.
- Widget `Provider` uses `@preconcurrency IntentTimelineProvider` to suppress Sendable warnings in `WidgetProviders.swift`.
- `AppDelegate` uses `@preconcurrency UNUserNotificationCenterDelegate` for notification delegate callbacks.
- `WatchConnectivityManager` is `NSObject, ObservableObject` — WCSession delegate callbacks arrive on arbitrary threads. Ensure UI updates dispatch to main actor.
- `HealthKitManager` is NOT `@MainActor` — its async methods may cross isolation boundaries when called from `@MainActor` ViewModels.
## State Management
### Cache / Offline Behavior
- SwiftData persists all mood data locally in the App Group shared container.
- CloudKit sync is automatic — works offline and syncs when connectivity returns.
- No explicit cache layer — SwiftData IS the cache and persistence.
- Widget reads from the same shared App Group container (local file, not CloudKit).
- Watch uses CloudKit for data sync (separate from WCSession which is for UI updates only).
- If CloudKit sync fails, local data is always available.
### Startup Flow
1. `ReflectApp.init()` — Configure `AnalyticsManager`, register `BGTaskScheduler`, reset `ReflectTipsManager`, initialize `LiveActivityScheduler`, initialize `WatchConnectivityManager`
2. `MainTabView` receives `DataController.shared.container` as `modelContainer`
3. `IAPManager`, `BiometricAuthManager`, `HealthKitManager` injected as `@EnvironmentObject`
4. On `scenePhase` change to `.active``DataController.shared.refreshFromDisk()` picks up widget/watch changes
5. On `scenePhase` change to `.background` — schedule `BGTask` for missing date backfill
## Test Conventions
### Framework & Location
- **Framework**: XCTest
- **UI test directory**: `Tests iOS/` (XCUITest suites)
- **Unit test directory**: `ReflectTests/` (XCTest unit tests)
- **macOS tests**: `Tests macOS/` (template only)
- **File naming**: `{SuiteName}Tests.swift`
### UI Test Architecture (XCUITest)
For any task that adds/updates UI tests, read:
- `/Users/treyt/Desktop/code/Feels/docs/XCUITest-Authoring.md`
Use this foundation:
- Base class: `/Users/treyt/Desktop/code/Feels/Tests iOS/Helpers/BaseUITestCase.swift`
- Wait + ID helpers: `/Users/treyt/Desktop/code/Feels/Tests iOS/Helpers/WaitHelpers.swift`
- Screen objects: `/Users/treyt/Desktop/code/Feels/Tests iOS/Screens/`
- Accessibility IDs: `/Users/treyt/Desktop/code/Feels/Shared/AccessibilityIdentifiers.swift`
- Test-mode fixtures: `/Users/treyt/Desktop/code/Feels/Shared/UITestMode.swift`
Mandatory UI test rules:
- Inherit from `BaseUITestCase`
- Use identifier-first selectors (`UITestID` / accessibility IDs)
- Use wait helpers and screen objects
- No `sleep(...)`
- No raw localized text selectors as primary locators
- Prefer one behavior per test method (`test<Feature>_<Behavior>`)
### UI Test Execution Commands
```bash
# Run one suite
xcodebuild -project Reflect.xcodeproj -scheme "Reflect (iOS)" -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:"Tests iOS/<SuiteName>" test
# Run all iOS UI tests
xcodebuild -project Reflect.xcodeproj -scheme "Reflect (iOS)" -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:"Tests iOS" test
```
### Unit Test Guidance
- Use in-memory `ModelContainer` (`isStoredInMemoryOnly: true`) for SwiftData isolation.
- Prefer protocol-based seams from `DataControllerProtocol.swift` when mocking data access.
- StoreKit flows should use StoreKit Testing config where needed.
### Bug Fix Protocol
When fixing a bug:
1. Reproduce with a failing test first when practical.
2. Add edge-case assertions for related boundaries.
3. Confirm targeted tests pass; run broader suite if behavior changed outside one area.
4. Name tests descriptively: `test{Component}_{WhatWasBroken}`
## Known Edge Cases & Gotchas
### Platform Gotchas
- Widget extensions cannot access CloudKit — they use local App Group storage via `ExtensionDataProvider`
- Widget extensions cannot access HealthKit or TipKit — `MoodLogger.logMood()` has `syncHealthKit` and `updateTips` flags for this
- watchOS uses CloudKit for data sync (automatic), WCSession only for UI update notifications
### Framework Gotchas
- SwiftData with CloudKit requires all `@Model` properties to have default values (CloudKit requirement)
- SwiftData `@Model` stores enums as raw `Int` values — `moodValue: Int` not `mood: Mood` directly
- `DataController.add()` deletes ALL existing entries for a date before inserting — this prevents duplicates but means "update" is really "delete + insert"
- `modelContext.rollback()` is used in `refreshFromDisk()` to pick up extension changes — this discards any unsaved in-process changes
- `SharedModelContainer.createWithFallback()` silently falls back to in-memory storage if App Group container fails — data loss risk if App Group is misconfigured
### Data Gotchas
- **Two entries for same date**: Handled by `add()` which deletes existing entries first, but could occur via CloudKit sync conflict
- **Mood value outside enum range**: `Mood(rawValue:)` returns `nil`, caught by `?? .missing` fallback in `MoodEntryModel.mood`
- **Timezone change crossing midnight**: `forDate` is stored as `Date``Calendar.current.startOfDay(for:)` used for comparisons, but timezone changes could shift which "day" an entry belongs to
- **Missing dates backfill**: `fillInMissingDates()` creates `.missing` entries for gaps — if it runs during timezone change, could create entries for wrong dates
- **Widget timeline with 0 entries**: `ExtensionDataProvider` returns empty array — widget must handle empty state
- **CloudKit sync conflict**: SwiftData/CloudKit uses last-writer-wins — no custom conflict resolution
### Common Developer Mistakes
- Calling `DataController.shared.add()` directly instead of `MoodLogger.shared.logMood()` — skips all side effects
- Creating a new `ModelContainer` instead of using `DataController.shared.container` — breaks shared state
- Accessing `DataController` from a background thread — it's `@MainActor`, will crash or produce undefined behavior
- Hardcoding App Group ID strings instead of using `Constants` or `SharedModelContainer.appGroupID`
- Forgetting to call `saveAndRunDataListeners()` after mutations — listeners won't fire, UI won't update
- Using `@ObservedObject` instead of `@StateObject` for ViewModels the view owns — causes recreation on every view update
## External Boundaries
| Boundary | Handler Class | Input Source | What Could Go Wrong |
|----------|--------------|-------------|---------------------|
| CloudKit sync | `SharedModelContainer` (automatic) | iCloud | Sync conflicts (last-writer-wins), offline delays, iCloud account not signed in |
| StoreKit 2 | `IAPManager` | App Store | Network timeout during purchase, receipt validation failure, subscription state mismatch |
| HealthKit | `HealthKitManager` | Health app | Permission denied, Health data unavailable, write conflicts |
| App Group storage | `SharedModelContainer`, `ExtensionDataProvider` | File system | Container URL nil (entitlement misconfigured), file corruption |
| Watch Connectivity | `WatchConnectivityManager` | WCSession | Watch not paired, session not reachable, message delivery failure |
| Background Tasks | `BGTask` | System scheduler | System may not run task, task killed by system, insufficient time |
| Apple Foundation Models | `FoundationModelsInsightService` | On-device AI | Model not available on device, generation failure, unexpected output |
| PostHog | `AnalyticsManager` | Network | analytics.88oakapps.com unreachable, event queue overflow |
| Deep Links | `ReflectApp.onOpenURL` | URL scheme `reflect://` | Malformed URL, unknown host/path |
| App Shortcuts / Siri | `AppShortcuts.swift` | SiriKit | Intent not recognized, missing parameter |
## Analytics
- **SDK**: PostHog (self-hosted at `analytics.88oakapps.com`)
- **Manager**: `AnalyticsManager.shared` in `Shared/Analytics.swift` — NEVER call `PostHogSDK` directly
- **Events**: Defined as `AnalyticsManager.Event` enum in `Analytics.swift`
- **Screens**: Defined as `AnalyticsManager.Screen` enum in `Analytics.swift`
- **Opt-out**: User can opt out via settings — `AnalyticsManager.isOptedOut`
- **Session Replay**: Supported, toggled via `AnalyticsManager.sessionReplayEnabled`
**Adding new analytics:**
```swift
// 1. Add case to AnalyticsManager.Event enum in Analytics.swift
case myNewEvent(param: String)
// 2. Add name and properties in the `payload` computed property switch
case .myNewEvent(let param):
return ("my_new_event", ["param": param])
// 3. Call from anywhere:
AnalyticsManager.shared.track(.myNewEvent(param: "value"))
// For screen tracking:
AnalyticsManager.shared.trackScreen(.day)
```
## Environment Configuration
- **Debug**: Uses `iCloud.com.88oakapps.reflect.debug` CloudKit container, `group.com.88oakapps.reflect.debug` App Group, `Reflect-Debug.store` filename
- **Production**: Uses `iCloud.com.88oakapps.reflect` CloudKit container, `group.com.88oakapps.reflect` App Group, `Reflect.store` filename
- **Toggle**: `#if DEBUG` preprocessor directive in `SharedModelContainer` and throughout codebase
- **StoreKit Testing**: `IAPManager.bypassSubscription` flag (DEBUG only) — set to `true` to bypass paywall during development
- **Subscription Group ID**: `21951685`
- **Product IDs**: `com.88oakapps.reflect.IAP.subscriptions.monthly`, `com.88oakapps.reflect.IAP.subscriptions.yearly`
- **Trial**: 30-day free trial tracked via `firstLaunchDate` in `GroupUserDefaults`
- **URL Scheme**: `reflect://` (deep links: `reflect://subscribe`)
- **BGTask ID**: `com.88oakapps.reflect.dbUpdateMissing`
- **Logger Subsystem**: `com.88oakapps.reflect`
## Directory Conventions
When adding new files:
- New views: `Shared/Views/{FeatureName}/`
- New view models: Colocated with views in `Shared/Views/{FeatureName}/`
- New models: `Shared/Models/`
- New services/managers: `Shared/Services/`
- New persistence operations: `Shared/Persisence/DataController{OPERATION}.swift` (note directory typo)
- New widget code: `ReflectWidget/`
- New watch code: `Reflect Watch App/`
- New UI tests: `Tests iOS/`
- New unit tests: `ReflectTests/`
### Naming Conventions
- **ViewModels**: `{Feature}ViewModel.swift` — e.g., `DayViewViewModel.swift`, `YearViewModel.swift`, `InsightsViewModel.swift`
- **Views**: `{Feature}View.swift` — e.g., `DayView.swift`, `MonthView.swift`, `YearView.swift`
- **Models**: `{ModelName}Model.swift` for SwiftData models — e.g., `MoodEntryModel.swift`
- **Services/Managers**: `{Purpose}Manager.swift` — e.g., `IAPManager.swift`, `HealthKitManager.swift`, `PhotoManager.swift`
- **DataController operations**: `DataController{OPERATION}.swift` — e.g., `DataControllerGET.swift`, `DataControllerADD.swift`
- **Tests**: `{SuiteName}Tests.swift` — e.g., `Tests_iOS.swift`
## Localization
- **Format**: String Catalogs (`Reflect/Localizable.xcstrings`)
- **Languages**: English (en), German (de), Spanish (es), French (fr), Japanese (ja), Korean (ko), Portuguese-Brazil (pt-BR)
- Use `String(localized:)` for all user-facing strings
- "Reflect" is a brand name — keep it untranslated in all languages
## Dependencies
### Package Manager
- No external SPM packages (pure Apple frameworks)
- Single external dependency: **PostHog** iOS SDK (added via SPM or directly)
### Key Frameworks
| Framework | Purpose |
|-----------|---------|
| SwiftData | Persistence and CloudKit sync |
| CloudKit | Automatic cloud sync via SwiftData |
| StoreKit 2 | Subscriptions and in-app purchases |
| HealthKit | Mood data sync to Health app |
| WidgetKit | Home screen and Lock Screen widgets |
| ActivityKit | Live Activities for mood streaks |
| BackgroundTasks | BGProcessingTask for missing date backfill |
| WatchConnectivity | Phone-watch communication |
| LocalAuthentication | Biometric auth (Face ID / Touch ID) |
| PostHog | Analytics and session replay |
| Foundation Models | On-device AI for mood insights |
## Mood Values
```swift
enum Mood: Int {
case horrible = 0
case bad = 1
case average = 2
case good = 3
case great = 4
case missing = 5 // Unfilled day (system-generated)
case placeholder = 6 // Calendar padding (system-generated)
}
```
## Entry Types
```swift
enum EntryType: Int, Codable {
case listView = 0 // Main app day view
case widget = 1 // Home screen widget
case watch = 2 // watchOS app
case shortcut = 3 // Shortcuts app
case filledInMissing = 4 // BGTask backfill
case notification = 5 // Push notification
case header = 6 // Header quick-entry
case siri = 7 // Siri / App Intent
case controlCenter = 8 // Control Center widget
case liveActivity = 9 // Live Activity
}
```