import SwiftUI import CoreData struct ContentView: View { @EnvironmentObject var iapService: IAPService @EnvironmentObject var adMobService: AdMobService @EnvironmentObject var tabSelection: TabSelectionStore @EnvironmentObject var coreDataStack: CoreDataStack @AppStorage("onboardingCompleted") private var onboardingCompleted = false @AppStorage("faceIdEnabled") private var faceIdEnabled = false @AppStorage("pinEnabled") private var pinEnabled = false @AppStorage("lockOnLaunch") private var lockOnLaunch = true @AppStorage("lockOnBackground") private var lockOnBackground = false @Environment(\.scenePhase) private var scenePhase @State private var isUnlocked = false @State private var resolvedOnboardingCompleted: Bool? @State private var iCloudCheckDone = false @State private var loadingMessageKey: LocalizedStringKey = "loading_data" private var lockEnabled: Bool { faceIdEnabled || pinEnabled } /// True when a fresh install with iCloud available and sync not yet enabled. /// Only relevant before onboarding is completed. private var needsICloudCheck: Bool { guard resolvedOnboardingCompleted == false else { return false } guard !UserDefaults.standard.bool(forKey: "cloudSyncEnabled") else { return false } return FileManager.default.ubiquityIdentityToken != nil } var body: some View { ZStack { Group { if !isReadyForContent { AppLaunchLoadingView(messageKey: loadingMessageKey) } else if needsICloudCheck && !iCloudCheckDone { // Fresh install with iCloud available: ask before showing onboarding OnboardingICloudCheckView(onSkip: { iCloudCheckDone = true }) } else if resolvedOnboardingCompleted == false { OnboardingView(onboardingCompleted: $onboardingCompleted) } else { mainContent } } if onboardingCompleted && lockEnabled && !isUnlocked { AppLockView(isUnlocked: $isUnlocked) } } .onAppear { if !lockEnabled { isUnlocked = true } else { isUnlocked = !lockOnLaunch } } .onChange(of: lockEnabled) { _, enabled in if !enabled { isUnlocked = true } else { isUnlocked = !lockOnLaunch } } .onChange(of: onboardingCompleted) { _, completed in if completed && lockEnabled { isUnlocked = !lockOnLaunch } } .onChange(of: scenePhase) { _, phase in guard onboardingCompleted, lockEnabled else { return } if phase == .background && lockOnBackground { isUnlocked = false } } .task { await waitForDataAndResolveOnboarding() } .onChange(of: onboardingCompleted) { _, completed in resolvedOnboardingCompleted = completed } } private var isReadyForContent: Bool { coreDataStack.isLoaded && resolvedOnboardingCompleted != nil } private func waitForDataAndResolveOnboarding() async { // Wait for Core Data to be loaded while !coreDataStack.isLoaded { try? await Task.sleep(nanoseconds: 50_000_000) // 50ms } // If CloudKit is enabled and no local data yet, wait briefly for the // initial iCloud sync so data from other devices appears before we // decide whether to show onboarding. if UserDefaults.standard.bool(forKey: "cloudSyncEnabled") && !hasExistingData() { await MainActor.run { loadingMessageKey = "checking_icloud" } await waitForInitialCloudKitSync(timeout: 10) await MainActor.run { loadingMessageKey = "loading_data" } } // Resolve onboarding state on main thread await MainActor.run { syncOnboardingState() } } /// Polls for existing data up to `timeout` seconds, returning as soon as /// any data appears. Used to wait for the initial CloudKit sync on launch. private func waitForInitialCloudKitSync(timeout: TimeInterval) async { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if hasExistingData() { return } try? await Task.sleep(nanoseconds: 300_000_000) // poll every 300ms } } private func syncOnboardingState() { let settings = AppSettings.getOrCreate(in: coreDataStack.viewContext) var resolved = settings.onboardingCompleted || onboardingCompleted // If user has existing data, skip onboarding if !resolved && hasExistingData() { resolved = true } // Persist the resolved state if settings.onboardingCompleted != resolved { settings.onboardingCompleted = resolved CoreDataStack.shared.save() } if onboardingCompleted != resolved { onboardingCompleted = resolved } resolvedOnboardingCompleted = resolved } private func hasExistingData() -> Bool { let context = coreDataStack.viewContext let sourceRequest: NSFetchRequest = InvestmentSource.fetchRequest() sourceRequest.fetchLimit = 1 sourceRequest.resultType = .countResultType if (try? context.count(for: sourceRequest)) ?? 0 > 0 { return true } let snapshotRequest: NSFetchRequest = Snapshot.fetchRequest() snapshotRequest.fetchLimit = 1 snapshotRequest.resultType = .countResultType if (try? context.count(for: snapshotRequest)) ?? 0 > 0 { return true } let accountRequest: NSFetchRequest = Account.fetchRequest() accountRequest.fetchLimit = 1 accountRequest.resultType = .countResultType return ((try? context.count(for: accountRequest)) ?? 0) > 0 } private var mainContent: some View { ZStack { TabView(selection: $tabSelection.selectedTab) { bannerInsetView(DashboardView()) .tabItem { Label("Home", systemImage: "house.fill") } .tag(0) bannerInsetView(SourceListView(iapService: iapService)) .tabItem { Label("Sources", systemImage: "list.bullet") } .tag(1) bannerInsetView(ChartsContainerView(iapService: iapService)) .tabItem { Label("Charts", systemImage: "chart.xyaxis.line") } .tag(2) bannerInsetView(JournalView()) .tabItem { Label("Journal", systemImage: "book.closed") } .tag(3) bannerInsetView(SettingsView(iapService: iapService)) .tabItem { Label("Settings", systemImage: "gearshape.fill") } .tag(4) } } } private func bannerInsetView(_ content: Content) -> some View { content.safeAreaInset(edge: .bottom, spacing: 0) { if !iapService.isPremium && adMobService.canShowAds { BannerAdView() .frame(height: AppConstants.UI.bannerAdHeight) .frame(maxWidth: .infinity) .background(Color(.systemBackground)) } } } } #Preview { ContentView() .environmentObject(CoreDataStack.shared) .environmentObject(IAPService()) .environmentObject(AdMobService()) .environmentObject(AccountStore(iapService: IAPService())) .environmentObject(TabSelectionStore()) }