Files
InvestmentTrackerApp/PortfolioJournal/App/ContentView.swift
T
alexandrev-tibco 326be6db04 1.6.0 #10 ocultar saldos + Face ID · #11 widget interactivo + Siri
#10 Ocultar saldos: BalancePrivacyManager (balancesHidden + requireBiometricToReveal),
modifier .hiddenBalance() (blur + máscara de bullets, ancho estable), botón ojo en Dashboard,
revelar con Face ID vía AppLockService, sección Privacidad en Settings. Aplicado a total
portfolio, forecast, sources, goals, monthly summary.

#11 Siri/App Intents: NetWorthIntent ('¿cuál es mi patrimonio?' → 'Tu patrimonio es X'),
StreakIntent, 5 AppShortcuts con frases. Widget interactivo iOS 17: botón Refresh
(Button(intent:) → reloadAllTimelines, in-process) en medium/large, gated #available(iOS 17).
Reader read-only del AppGroup. Dialogs Siri refactorizados a String(localized:).

Localización: strings de #10 y de Siri en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 17:52:09 +02:00

343 lines
13 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
@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()
// 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(nanoseconds: 300_000_000)
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 = 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> = 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: - 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: 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)
}