# 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_`) ### 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/" 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**: `21914363` - **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 } ```