- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
6.7 KiB
Swift
165 lines
6.7 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 {
|
|
private enum AnimationConstants {
|
|
static let deepLinkHandlingDelay: TimeInterval = 0.3
|
|
}
|
|
|
|
@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) { }
|
|
.accessibilityIdentifier(AccessibilityID.AppAlert.storageUnavailableOK)
|
|
} 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() + AnimationConstants.deepLinkHandlingDelay) {
|
|
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
|
|
}
|
|
}
|
|
}
|