23 KiB
Feels
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
# Build
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' build
# Run all tests
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
-
Models (
Shared/Models/): Data types and domain logicMoodEntryModel— SwiftData@Modelfor mood entriesMood— 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
-
Persistence (
Shared/Persisence/): SwiftData operations (note: directory has a typo — "Persisence" not "Persistence")DataController— Singleton@MainActordata access layerDataControllerGET— Read/fetch operationsDataControllerADD— Create operationsDataControllerUPDATE— Update operationsDataControllerDELETE— Delete operationsDataControllerHelper— Utility methodsDataControllerProtocol— Protocol definitions for testabilitySharedModelContainer— Factory forModelContainer(shared App Group storage)ExtensionDataProvider— Data provider for widget and watch extensions
-
ViewModels (colocated with views):
DayViewViewModel— Day/Month grid data, mood loggingYearViewModel— Year view filtering and chart dataInsightsViewModel— AI-powered insights via Apple Foundation ModelsIconViewModel— Custom app icon selectionCustomWidgetStateViewModel— Widget customization state
-
Views (
Shared/Views/): SwiftUI views organized by featureDayView/,MonthView/,YearView/,InsightsView/SettingsView/,CustomizeView/,CustomWidget/Sharing/,SharingTemplates/
-
Services (
Shared/Services/andShared/): Business logic singletonsMoodLogger— Centralized mood logging with side effectsIAPManager— StoreKit 2 subscriptionsAnalyticsManager— PostHog analyticsHealthKitManager— HealthKit mood syncBiometricAuthManager— Face ID / Touch IDPhotoManager— Photo attachment handlingWatchConnectivityManager— Watch-phone communicationLiveActivityScheduler— Live Activity lifecycleReviewRequestManager— App Store review promptsFoundationModelsInsightService— 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
// ✅ 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
// ❌ 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 intomodelContext(this IS the data layer)ExtensionDataProvider— Creates its ownModelContainerfor widget/watch (can't share process)- Widget extensions — May call
DataController.add()directly whenMoodLoggerisn'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.sharedmethods (getEntry,getData,splitIntoYearMonth). Views MUST NOT use@Queryor directmodelContextaccess. - All analytics MUST go through
AnalyticsManager.shared. NEVER callPostHogSDKdirectly. - Any code that modifies mood data MUST call
WidgetCenter.shared.reloadAllTimelines()(handled automatically byMoodLogger). - NEVER access
DataController.sharedfrom a background thread — it is@MainActorisolated. - NEVER hardcode App Group identifiers — use
Constants.groupShareId/Constants.groupShareIdDebugorSharedModelContainer.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 classconforming toObservableObject. - ALWAYS use
@StateObject(not@ObservedObject) when a view owns its ViewModel. - NEVER bypass
IAPManagersubscription checks — useIAPManager.shared.shouldShowPaywallto 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
// ✅ 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
// ❌ 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
DataControlleris@MainActor final class— all SwiftData operations run on main actor.MoodLoggeris@MainActor final class— mood logging and side effects run on main actor.AnalyticsManageris@MainActor final class— analytics calls run on main actor.IAPManageris@MainActor class ObservableObject— StoreKit state is main-actor bound.ExtensionDataProvideris@MainActor final class— extension data access on main actor.HealthKitManagerisclass ObservableObject(NOT @MainActor) — HealthKit calls may be async.WatchConnectivityManagerisNSObject, ObservableObject— WCSession delegate callbacks.- All ViewModels (
DayViewViewModel,YearViewModel,InsightsViewModel) are@MainActor class ObservableObject. - Background work (HealthKit sync) MUST use
Task { }from@MainActorcontext. - CloudKit sync happens automatically via SwiftData's built-in CloudKit integration — no manual threading.
BGTask.swiftrunsfillInMissingDates()which accessesDataController.sharedon the main actor.
Swift 6 / Sendable Notes
Colorgets@retroactive Codableand@retroactive RawRepresentableconformance inColor+Codable.swift.Dategets@retroactive RawRepresentableconformance inDate+Extensions.swift.- Widget
Provideruses@preconcurrency IntentTimelineProviderto suppress Sendable warnings inWidgetProviders.swift. AppDelegateuses@preconcurrency UNUserNotificationCenterDelegatefor notification delegate callbacks.WatchConnectivityManagerisNSObject, ObservableObject— WCSession delegate callbacks arrive on arbitrary threads. Ensure UI updates dispatch to main actor.HealthKitManageris NOT@MainActor— its async methods may cross isolation boundaries when called from@MainActorViewModels.
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
FeelsApp.init()— ConfigureAnalyticsManager, registerBGTaskScheduler, resetFeelsTipsManager, initializeLiveActivityScheduler, initializeWatchConnectivityManagerMainTabViewreceivesDataController.shared.containerasmodelContainerIAPManager,BiometricAuthManager,HealthKitManagerinjected as@EnvironmentObject- On
scenePhasechange to.active—DataController.shared.refreshFromDisk()picks up widget/watch changes - On
scenePhasechange to.background— scheduleBGTaskfor missing date backfill
Test Conventions
Framework & Location
- Framework: XCTest
- Test directory:
Tests iOS/(iOS),Tests macOS/(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
# Run one suite
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -only-testing:"Tests iOS/<SuiteName>" test
# Run all iOS UI tests
xcodebuild -project Feels.xcodeproj -scheme "Feels (iOS)" -destination 'platform=iOS Simulator,name=iPhone 16 Pro' -only-testing:"Tests iOS" test
Unit Test Guidance
- Use in-memory
ModelContainer(isStoredInMemoryOnly: true) for SwiftData isolation. - Prefer protocol-based seams from
DataControllerProtocol.swiftwhen mocking data access. - StoreKit flows should use StoreKit Testing config where needed.
Bug Fix Protocol
When fixing a bug:
- Reproduce with a failing test first when practical.
- Add edge-case assertions for related boundaries.
- Confirm targeted tests pass; run broader suite if behavior changed outside one area.
- 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()hassyncHealthKitandupdateTipsflags for this - watchOS uses CloudKit for data sync (automatic), WCSession only for UI update notifications
Framework Gotchas
- SwiftData with CloudKit requires all
@Modelproperties to have default values (CloudKit requirement) - SwiftData
@Modelstores enums as rawIntvalues —moodValue: Intnotmood: Mooddirectly 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 inrefreshFromDisk()to pick up extension changes — this discards any unsaved in-process changesSharedModelContainer.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:)returnsnil, caught by?? .missingfallback inMoodEntryModel.mood - Timezone change crossing midnight:
forDateis stored asDate—Calendar.current.startOfDay(for:)used for comparisons, but timezone changes could shift which "day" an entry belongs to - Missing dates backfill:
fillInMissingDates()creates.missingentries for gaps — if it runs during timezone change, could create entries for wrong dates - Widget timeline with 0 entries:
ExtensionDataProviderreturns 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 ofMoodLogger.shared.logMood()— skips all side effects - Creating a new
ModelContainerinstead of usingDataController.shared.container— breaks shared state - Accessing
DataControllerfrom a background thread — it's@MainActor, will crash or produce undefined behavior - Hardcoding App Group ID strings instead of using
ConstantsorSharedModelContainer.appGroupID - Forgetting to call
saveAndRunDataListeners()after mutations — listeners won't fire, UI won't update - Using
@ObservedObjectinstead of@StateObjectfor 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.sharedinShared/Analytics.swift— NEVER callPostHogSDKdirectly - Events: Defined as
AnalyticsManager.Eventenum inAnalytics.swift - Screens: Defined as
AnalyticsManager.Screenenum inAnalytics.swift - Opt-out: User can opt out via settings —
AnalyticsManager.isOptedOut - Session Replay: Supported, toggled via
AnalyticsManager.sessionReplayEnabled
Adding new analytics:
// 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.debugCloudKit container,group.com.88oakapps.feels.debugApp Group,Feels-Debug.storefilename - Production: Uses
iCloud.com.88oakapps.feelsCloudKit container,group.com.88oakapps.feelsApp Group,Feels.storefilename - Toggle:
#if DEBUGpreprocessor directive inSharedModelContainerand throughout codebase - StoreKit Testing:
IAPManager.bypassSubscriptionflag (DEBUG only) — set totrueto 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
firstLaunchDateinGroupUserDefaults
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.swiftfor 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
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
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
}