Files
Reflect/Shared/ReflectApp.swift
Trey t 31fb2a7fe2 Add weather feature with WeatherKit integration for mood entries
Fetch and display weather data (temp, condition, hi/lo, humidity) when
users log a mood. Weather is stored as JSON on MoodEntryModel and shown
as a card in EntryDetailView. Premium-gated with location permission
prompt. Includes BGTask retry for failed fetches and full analytics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:16:26 -05:00

160 lines
6.5 KiB
Swift

//
// ReflectApp.swift
// Shared
//
// Created by Trey Tartt on 1/5/22.
//
import SwiftUI
import SwiftData
import BackgroundTasks
import WidgetKit
@main
struct ReflectApp: 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() {
// Configure UI test mode before anything else
if UITestMode.isUITesting {
UITestMode.configureIfNeeded()
}
AnalyticsManager.shared.configure()
BGTaskScheduler.shared.cancelAllTaskRequests()
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.updateDBMissingID, using: nil) { (task) in
guard let processingTask = task as? BGProcessingTask else { return }
BGTask.runFillInMissingDatesTask(task: processingTask)
}
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.weatherRetryID, using: nil) { task in
guard let processingTask = task as? BGProcessingTask else { return }
BGTask.runWeatherRetryTask(task: processingTask)
}
UNUserNotificationCenter.current().setBadgeCount(0)
// Reset tips session on app launch
ReflectTipsManager.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) {
ReflectSubscriptionStoreView(source: "widget_deeplink")
.environmentObject(iapManager)
}
.onOpenURL { url in
handleDeepLink(url)
}
.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)
}
if let url = AppDelegate.pendingDeepLinkURL {
AppDelegate.pendingDeepLinkURL = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
handleDeepLink(url)
}
}
}
// 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(priority: .utility) {
// 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(priority: .utility) {
await LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
}
// Catch up on side effects from widget/watch votes
Task(priority: .utility) {
await MoodLogger.shared.processPendingSideEffects()
}
// Check subscription status (network call) - throttled
Task(priority: .background) {
await iapManager.checkSubscriptionStatus()
await iapManager.trackSubscriptionAnalytics(source: "app_foreground")
}
}
}
}
private func handleDeepLink(_ url: URL) {
if url.scheme == "reflect" && url.host == "subscribe" {
showSubscriptionFromWidget = true
}
}
}