Files
InvestmentTrackerApp/PortfolioJournal/App/ContentView.swift
T
alexandrev-tibco 680255f69d iPhone Duo (fase 1): navegación adaptativa por size class y layouts regular×regular
- ContentView: un único TabView (sidebarAdaptable en iOS 18+) para todas las
  size classes; desaparece el cambio de jerarquía iPhone/iPad, así abrir o
  cerrar el Duo conserva el estado de cada pestaña. @SceneStorage de la tab.
- Fuentes y Diario: NavigationSplitView (lista + detalle a la vez en ancho
  regular, stack colapsable en compacto) con selección nativa de List.
- Dashboard: Quick Update como .inspector (panel lateral junto a los gráficos
  en regular, sheet en compacto).
- Gráficos: chartHeightScale (+35% de alto en regular) vía chartFrame(height:),
  sin ignoresSafeArea en contenido interactivo.
- Liquid Glass (glassEffect) en las etiquetas flotantes del Diario en iOS 26+.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:50 +02:00

324 lines
12 KiB
Swift

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> = InvestmentSource.fetchRequest()
sourceRequest.fetchLimit = 1
sourceRequest.resultType = .countResultType
if (try? context.count(for: sourceRequest)) ?? 0 > 0 {
return true
}
let snapshotRequest: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
snapshotRequest.fetchLimit = 1
snapshotRequest.resultType = .countResultType
if (try? context.count(for: snapshotRequest)) ?? 0 > 0 {
return true
}
let accountRequest: NSFetchRequest<Account> = 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: View>(_ 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)
}