Files
InvestmentTrackerApp/PortfolioJournal/App/ContentView.swift
T
alexandrev-tibco 7d2f605c16 quitar AdMob, Firebase Analytics y ATT de la app
"Portfolio Journal sin AdMob ni Google: es la única forma de que 'sin
rastreo' sea verdad, y el ingreso por anuncios es despreciable" — la ficha
de App Store prometía "sin analíticas, sin rastreo, sin venta de datos"
mientras la app enlazaba GoogleMobileAds y FirebaseAnalytics, pedía permiso
de rastreo al arrancar y mandaba el importe del saldo a GA4 en el evento
snapshot_added.

SDKs fuera del proyecto (project.pbxproj): paquetes firebase-ios-sdk y
swift-package-manager-google-mobile-ads, productos FirebaseCore,
FirebaseAnalytics, GoogleMobileAds y FirebaseCrashlytics, más la fase de
build "Upload dSYMs to Crashlytics". Package.resolved se queda sin nada que
resolver. Con la fase de script fuera, ENABLE_USER_SCRIPT_SANDBOXING vuelve
a YES en el target app (estaba en NO solo por Crashlytics).

Código borrado:
- Services/AdMobService.swift entero — con él se van el flujo UMP,
  BannerAdView, BannerAdCoordinator y la llamada a
  ATTrackingManager.requestTrackingAuthorization().
- Services/FirebaseService.swift entero y sus 40 llamadas. Se borra en vez
  de dejarse como capa vacía: una clase llamada FirebaseService en una app
  que presume de no llevar Firebase es exactamente la clase de detalle que
  vuelve a colarse en una auditoría dentro de un año.
- El banner y su safeAreaInset en ContentView (bannerInsetView): las cinco
  pestañas ya no reservan 50 pt al pie.
- La entrada "Manage Ad Consent" de Ajustes y el @EnvironmentObject
  adMobService de SettingsView, ContentView y PortfolioJournalApp.
- AppConstants: bloque AdMob, bannerAdHeight, adConsentObtained,
  Features.enableAnalytics.
- SettingsViewModel.toggleAnalytics y analyticsEnabled — el flag no estaba
  conectado a nada ni tenía control en la interfaz. El atributo
  AppSettings.enableAnalytics se queda en CoreData con un comentario: no
  vale la pena una migración y un deploy de esquema CloudKit por borrarlo.
- Premium ya no promete "sin anuncios": fuera PremiumFeature.noAds, la
  entrada de IAPService.premiumFeatures y paywallBenefits, y las claves
  feature_no_ads / paywall_benefit_noads de los 7 idiomas. No se toca ni el
  precio ni el producto.

Info.plist: fuera NSUserTrackingUsageDescription, GADApplicationIdentifier,
GADDelayAppMeasurementInit y los 65 SKAdNetworkItems. Borrados también
GoogleService-Info.plist y los scripts Scripts/analyze_ga4.py y
Scripts/analyze_crashlytics.py, que ya no tienen de dónde leer.

Closes #50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-18 16:09:04 +02:00

311 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 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:
DashboardView()
case .sources:
SourceListView(iapService: iapService)
case .charts:
ChartsContainerView(iapService: iapService)
case .journal:
JournalView()
case .settings:
SettingsView(iapService: iapService)
}
}
}
#Preview {
ContentView()
.environmentObject(CoreDataStack.shared)
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
.environmentObject(TabSelectionStore())
.environmentObject(BalancePrivacyManager.shared)
}