Files
Reflect/Shared/FeelsApp.swift
Trey t f1cd81c395 Fix 7 data mutation layer risks identified in audit
- save()/saveAndRunDataListeners() now return @discardableResult Bool;
  listeners only fire on successful save
- MoodLogger.updateMood() now recalculates streak, updates Live Activity,
  and notifies watch (was missing these side effects)
- CSV import uses new addBatch()/importMoods() for O(1) side effects
  instead of O(n) per-row widget reloads and streak calcs
- Foreground task ordering: fillInMissingDates() now runs before
  removeDuplicates() so backfill-created duplicates are caught same cycle
- WidgetMoodSaver deletes ALL entries for date (was fetchLimit=1, leaving
  CloudKit sync duplicates behind)
- cleanupPhotoIfNeeded logs warning on failed photo deletion instead of
  silently orphaning files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:09:11 -06:00

141 lines
5.7 KiB
Swift

//
// FeelsApp.swift
// Shared
//
// Created by Trey Tartt on 1/5/22.
//
import SwiftUI
import SwiftData
import BackgroundTasks
import WidgetKit
@main
struct FeelsApp: App {
@Environment(\.scenePhase) private var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
let dataController = DataController.shared
@StateObject var iapManager = IAPManager.shared
@StateObject var authManager = BiometricAuthManager()
@StateObject var healthKitManager = HealthKitManager.shared
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
@State private var showSubscriptionFromWidget = false
@State private var showStorageFallbackAlert = SharedModelContainer.isUsingInMemoryFallback
init() {
AnalyticsManager.shared.configure()
BGTaskScheduler.shared.cancelAllTaskRequests()
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.updateDBMissingID, using: nil) { (task) in
BGTask.runFillInMissingDatesTask(task: task as! BGProcessingTask)
}
UNUserNotificationCenter.current().setBadgeCount(0)
// Reset tips session on app launch
FeelsTipsManager.shared.resetSession()
// Initialize Live Activity scheduler
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
// Initialize Watch Connectivity for cross-device widget updates
_ = WatchConnectivityManager.shared
}
var body: some Scene {
WindowGroup {
ZStack {
MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)),
monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)),
yearView: YearView(viewModel: YearViewModel()),
insightsView: InsightsView())
.modelContainer(dataController.container)
.environmentObject(iapManager)
.environmentObject(authManager)
.environmentObject(healthKitManager)
.sheet(isPresented: $showSubscriptionFromWidget) {
FeelsSubscriptionStoreView(source: "widget_deeplink")
.environmentObject(iapManager)
}
.onOpenURL { url in
if url.scheme == "feels" && url.host == "subscribe" {
showSubscriptionFromWidget = true
}
}
.alert("Data Storage Unavailable",
isPresented: $showStorageFallbackAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("Your mood data cannot be saved permanently. Please restart the app. If the problem persists, reinstall the app.")
}
.onAppear {
if SharedModelContainer.isUsingInMemoryFallback {
AnalyticsManager.shared.track(.storageFallbackActivated)
}
}
// Lock screen overlay
if authManager.isLockEnabled && !authManager.isUnlocked {
LockScreenView(authManager: authManager)
.transition(.opacity)
}
}
}.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
BGTask.scheduleBackgroundProcessing()
WidgetCenter.shared.reloadAllTimelines()
// Flush pending analytics events
AnalyticsManager.shared.flush()
// Lock the app when going to background
authManager.lock()
}
if newPhase == .active {
UNUserNotificationCenter.current().setBadgeCount(0)
// Authenticate if locked - this must happen immediately on main thread
if authManager.isLockEnabled && !authManager.isUnlocked {
Task {
await authManager.authenticate()
}
}
// Defer all non-critical foreground work to avoid blocking UI
Task.detached(priority: .utility) { @MainActor in
// Refresh from disk to pick up widget/watch changes
DataController.shared.refreshFromDisk()
// Fill in any missing dates (moved from AppDelegate)
DataController.shared.fillInMissingDates()
// Clean up any duplicate entries (after backfill so backfill dupes are caught)
DataController.shared.removeDuplicates()
// Reschedule notifications for new title
LocalNotification.rescheduleNotifiations()
// Update super properties on foreground
AnalyticsManager.shared.updateSuperProperties()
}
// Defer Live Activity scheduling (heavy DB operations)
Task.detached(priority: .utility) {
await LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
}
// Catch up on side effects from widget/watch votes
Task.detached(priority: .utility) {
await MoodLogger.shared.processPendingSideEffects()
}
// Check subscription status (network call) - throttled
Task.detached(priority: .background) {
await iapManager.checkSubscriptionStatus()
await iapManager.trackSubscriptionAnalytics(source: "app_foreground")
}
}
}
}
}