import SwiftUI import CoreData // MARK: - App Tab enum AppTab: Int, Hashable, CaseIterable { case dashboard = 0, sources = 1, charts = 2, journal = 3, settings = 4 var title: String { switch self { case .dashboard: String(localized: "tab_dashboard") case .sources: String(localized: "tab_sources") case .charts: String(localized: "tab_charts") case .journal: "Journal" case .settings: String(localized: "tab_settings") } } var icon: String { switch self { case .dashboard: "house.fill" case .sources: "list.bullet" case .charts: "chart.xyaxis.line" case .journal: "book.closed" case .settings: "gearshape.fill" } } } // MARK: - Content View 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 @AppStorage("lastSeenWhatsNewVersion") private var lastSeenWhatsNewVersion = "" @Environment(\.scenePhase) private var scenePhase @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var isUnlocked = false @State private var resolvedOnboardingCompleted: Bool? @State private var iCloudCheckDone = false @State private var loadingMessageKey: LocalizedStringKey = "loading_data" @State private var sidebarSelection: AppTab? = .dashboard @State private var showingWhatsNew = false private var currentVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" } 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 if horizontalSizeClass == .regular { iPadMainContent } 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() if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion { // Small delay to let the UI settle before showing sheet try? await Task.sleep(nanoseconds: 300_000_000) showingWhatsNew = true } } .onChange(of: onboardingCompleted) { _, completed in resolvedOnboardingCompleted = completed } .sheet(isPresented: $showingWhatsNew, onDismiss: { lastSeenWhatsNewVersion = currentVersion }) { WhatsNewView() } .onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in tabSelection.selectedTab = 1 sidebarSelection = .sources NotificationCenter.default.post(name: .openQuickUpdate, object: nil) } .onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in tabSelection.selectedTab = 0 sidebarSelection = .dashboard } .onOpenURL { url in if url.host == "quickupdate" { tabSelection.selectedTab = 0 sidebarSelection = .dashboard NotificationCenter.default.post(name: .openQuickUpdate, object: nil) } } } 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 } // MARK: - iPad Layout private var iPadMainContent: some View { NavigationSplitView(columnVisibility: .constant(.all)) { List(AppTab.allCases, id: \.self, selection: $sidebarSelection) { tab in Label(tab.title, systemImage: tab.icon) } .navigationTitle("Portfolio Journal") .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 280) } detail: { iPadDetailView(for: sidebarSelection ?? .dashboard) } .onChange(of: sidebarSelection) { _, tab in guard let tab else { return } if tabSelection.selectedTab != tab.rawValue { tabSelection.selectedTab = tab.rawValue } } .onChange(of: tabSelection.selectedTab) { _, value in if let tab = AppTab(rawValue: value), sidebarSelection != tab { sidebarSelection = tab } } } @ViewBuilder private func iPadDetailView(for tab: AppTab) -> some View { switch tab { case .dashboard: bannerInsetView(DashboardView()) case .sources: bannerInsetView(SourceListView(iapService: iapService)) case .charts: bannerInsetView(ChartsContainerView(iapService: iapService)) case .journal: bannerInsetView(JournalView()) case .settings: bannerInsetView(SettingsView(iapService: iapService)) } } // MARK: - iPhone Layout 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()) }