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 @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 showingWhatsNew = false /// Per-scene tab restoration: the interface is re-laid out when iPhone Duo /// opens/closes or a window is resized — the selected tab must survive it. @SceneStorage("selectedTab") private var restoredTab: Int = -1 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 { mainContent } } if onboardingCompleted && lockEnabled && !isUnlocked { AppLockView(isUnlocked: $isUnlocked) } } .onAppear { if !lockEnabled { isUnlocked = true } else { isUnlocked = !lockOnLaunch } if restoredTab >= 0, AppTab(rawValue: restoredTab) != nil { tabSelection.selectedTab = restoredTab } } .onChange(of: tabSelection.selectedTab) { _, tab in restoredTab = tab } .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() // Cumulative What's New: everything between the version the user // comes from and the one they just installed. Empty catalog diff // (e.g. patch release without entry) → no sheet. if (resolvedOnboardingCompleted == true) && currentVersion != lastSeenWhatsNewVersion, !WhatsNewCatalog.pendingReleases(since: lastSeenWhatsNewVersion, current: currentVersion).isEmpty { // Small delay to let the UI settle before showing sheet try? await Task.sleep(for: .milliseconds(300)) showingWhatsNew = true } } .onChange(of: onboardingCompleted) { _, completed in resolvedOnboardingCompleted = completed // Fresh install: nothing is "new" for a user who just onboarded. if completed && lastSeenWhatsNewVersion.isEmpty { lastSeenWhatsNewVersion = currentVersion // Straight to the aha moment: open Add Source instead of // dropping the new user on an empty dashboard. DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { NotificationCenter.default.post(name: .openAddFirstSource, object: nil) } } } .sheet(isPresented: $showingWhatsNew, onDismiss: { lastSeenWhatsNewVersion = currentVersion }) { WhatsNewView(releases: WhatsNewCatalog.pendingReleases( since: lastSeenWhatsNewVersion, current: currentVersion )) } .onReceive(NotificationCenter.default.publisher(for: .openBatchUpdate)) { _ in tabSelection.selectedTab = AppTab.sources.rawValue NotificationCenter.default.post(name: .openQuickUpdate, object: nil) } .onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in tabSelection.selectedTab = AppTab.dashboard.rawValue } .onOpenURL { url in if url.host == "quickupdate" { tabSelection.selectedTab = AppTab.dashboard.rawValue 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(for: .milliseconds(50)) // 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(for: .milliseconds(300)) // 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: - Adaptive navigation // // One TabView for every size class. iOS 18+ uses the sidebar-adaptable // style: a tab bar in compact width (iPhone, iPhone Duo outer screen) and a // top tab bar / sidebar in regular width (iPad, iPhone Duo inner screen). // Because the hierarchy never changes, opening or closing the Duo keeps // every tab's navigation and scroll state. Each tab that has a list+detail // structure (Sources, Journal) owns a NavigationSplitView that collapses // to a stack in compact width and shows both columns in regular width. @ViewBuilder private var mainContent: some View { if #available(iOS 18.0, *) { adaptiveTabs } else { legacyTabs } } @available(iOS 18.0, *) private var adaptiveTabs: some View { TabView(selection: $tabSelection.selectedTab) { ForEach(AppTab.allCases, id: \.self) { tab in Tab(tab.title, systemImage: tab.icon, value: tab.rawValue) { tabContent(for: tab) } } } .tabViewStyle(.sidebarAdaptable) } /// iOS 17 fallback: classic tab bar in every size class. private var legacyTabs: some View { TabView(selection: $tabSelection.selectedTab) { ForEach(AppTab.allCases, id: \.self) { tab in tabContent(for: tab) .tabItem { Label(tab.title, systemImage: tab.icon) } .tag(tab.rawValue) } } } @ViewBuilder private func tabContent(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)) } } 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()) .environmentObject(BalancePrivacyManager.shared) }