update claude md
This commit is contained in:
484
CLAUDE.md
484
CLAUDE.md
@@ -1,48 +1,431 @@
|
|||||||
# Feels - Claude Code Context
|
# Feels
|
||||||
|
|
||||||
## Project Summary
|
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.
|
||||||
|
|
||||||
Feels is an iOS mood tracking app. Users rate their day on a 5-point scale (Horrible → Great) and view patterns via Day, Month, and Year views.
|
## Build & Run Commands
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **Pattern**: MVVM with SwiftUI
|
|
||||||
- **Data**: Core Data with CloudKit sync
|
|
||||||
- **Monetization**: StoreKit 2 subscriptions (30-day trial, monthly/yearly plans)
|
|
||||||
|
|
||||||
## Key Directories
|
|
||||||
|
|
||||||
```
|
|
||||||
Shared/ # Core app code (Models, Views, Persistence)
|
|
||||||
FeelsWidget2/ # Widget extension
|
|
||||||
Feels Watch App/ # watchOS companion
|
|
||||||
docs/ # ASO and competitive analysis
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Layer
|
|
||||||
|
|
||||||
Core Data operations are split across files in `Shared/Persistence/`:
|
|
||||||
- `Persistence.swift` - Core Data stack setup
|
|
||||||
- `PersistenceGET.swift` - Fetch operations
|
|
||||||
- `PersistenceADD.swift` - Create entries
|
|
||||||
- `PersistenceUPDATE.swift` - Update operations
|
|
||||||
- `PersistenceDELETE.swift` - Delete operations
|
|
||||||
|
|
||||||
## App Groups
|
|
||||||
|
|
||||||
- **Production**: `group.com.88oakapps.feels`
|
|
||||||
- **Debug**: `group.com.88oakapps.feels.debug`
|
|
||||||
|
|
||||||
## Build & Run
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the app
|
# Build
|
||||||
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
|
||||||
|
|
||||||
# Run tests
|
# Run all tests
|
||||||
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' test
|
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' test
|
||||||
|
|
||||||
|
# Run a single test suite
|
||||||
|
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -only-testing:"Tests iOS/Tests_iOS" test
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 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. `FeelsApp.init()` — Configure `AnalyticsManager`, register `BGTaskScheduler`, reset `FeelsTipsManager`, 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
|
||||||
|
- **Test directory**: `Tests iOS/` (iOS), `Tests macOS/` (macOS — template only)
|
||||||
|
- **File naming**: `{SuiteName}Tests.swift`
|
||||||
|
|
||||||
|
### Existing Test Suites
|
||||||
|
|
||||||
|
| Suite | Test Count | Covers |
|
||||||
|
|-------|-----------|--------|
|
||||||
|
| `Tests_iOS` | 2 | `Date.dates(from:toDate:)` utility — basic date range generation |
|
||||||
|
| `Tests_iOSLaunchTests` | 1 | Default launch test (template) |
|
||||||
|
|
||||||
|
**Note**: Test coverage is minimal. Most of the app is untested. Priority areas for new tests: `DataController` CRUD operations, `MoodLogger` side effects, `IAPManager` subscription state transitions, `MoodEntryModel` initialization edge cases.
|
||||||
|
|
||||||
|
### Naming Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
test{Component}_{Behavior}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `testDatesBetween`, `testDatesIncluding`
|
||||||
|
|
||||||
|
### Mocking Strategy
|
||||||
|
|
||||||
|
- **SwiftData**: Use `ModelContainer` with `isStoredInMemoryOnly: true` for test isolation
|
||||||
|
- **DataController**: The `DataControllerProtocol.swift` defines `MoodDataReading`, `MoodDataWriting`, `MoodDataDeleting`, `MoodDataPersisting` protocols — use these for protocol-based mocking
|
||||||
|
- **Analytics**: No mock needed — `AnalyticsManager` can be stubbed or ignored in tests
|
||||||
|
- **HealthKit**: Mock `HKHealthStore` or skip — not critical for unit tests
|
||||||
|
- **StoreKit**: Use StoreKit Testing in Xcode for `IAPManager` tests
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```swift
|
||||||
|
// Test setup with in-memory SwiftData
|
||||||
|
let schema = Schema([MoodEntryModel.self])
|
||||||
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
|
let container = try ModelContainer(for: schema, configurations: [config])
|
||||||
|
let context = container.mainContext
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug Fix Protocol
|
||||||
|
|
||||||
|
When fixing a bug:
|
||||||
|
1. Write a regression test that reproduces the bug BEFORE fixing it
|
||||||
|
2. Include edge cases — test boundary conditions, nil/empty inputs, related scenarios
|
||||||
|
3. Confirm all tests pass after the fix
|
||||||
|
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 | `FeelsApp.onOpenURL` | URL scheme `feels://` | 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.feels.debug` CloudKit container, `group.com.88oakapps.feels.debug` App Group, `Feels-Debug.store` filename
|
||||||
|
- **Production**: Uses `iCloud.com.88oakapps.feels` CloudKit container, `group.com.88oakapps.feels` App Group, `Feels.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.feels.IAP.subscriptions.monthly`, `com.88oakapps.feels.IAP.subscriptions.yearly`
|
||||||
|
- **Trial**: 30-day free trial tracked via `firstLaunchDate` in `GroupUserDefaults`
|
||||||
|
|
||||||
|
## 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: `FeelsWidget2/`
|
||||||
|
- New watch code: `Feels Watch App/`
|
||||||
|
- New tests: `Tests iOS/`
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- English: `en.lproj/Localizable.strings`
|
||||||
|
- Spanish: `es.lproj/Localizable.strings`
|
||||||
|
- Use `String(localized:)` for all user-facing strings
|
||||||
|
|
||||||
|
## 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
|
## Mood Values
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
@@ -52,19 +435,24 @@ enum Mood: Int {
|
|||||||
case average = 2
|
case average = 2
|
||||||
case good = 3
|
case good = 3
|
||||||
case great = 4
|
case great = 4
|
||||||
case missing = 5 // Unfilled day
|
case missing = 5 // Unfilled day (system-generated)
|
||||||
case placeholder = 6 // System-generated
|
case placeholder = 6 // Calendar padding (system-generated)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Localization
|
## Entry Types
|
||||||
|
|
||||||
- English: `en.lproj/Localizable.strings`
|
```swift
|
||||||
- Spanish: `es.lproj/Localizable.strings`
|
enum EntryType: Int, Codable {
|
||||||
|
case listView = 0 // Main app day view
|
||||||
## Important Patterns
|
case widget = 1 // Home screen widget
|
||||||
|
case watch = 2 // watchOS app
|
||||||
1. **Widgets** update via `WidgetCenter.shared.reloadAllTimelines()`
|
case shortcut = 3 // Shortcuts app
|
||||||
2. **Missing dates** are auto-filled by background task (`BGTask.swift`)
|
case filledInMissing = 4 // BGTask backfill
|
||||||
3. **Entry types** distinguish user entries from system-generated ones
|
case notification = 5 // Push notification
|
||||||
4. **Customization** uses protocols: `Themeable`, `MoodTintable`, `MoodImagable`, `PersonalityPackable`
|
case header = 6 // Header quick-entry
|
||||||
|
case siri = 7 // Siri / App Intent
|
||||||
|
case controlCenter = 8 // Control Center widget
|
||||||
|
case liveActivity = 9 // Live Activity
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user