7d2f605c16
"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
311 lines
12 KiB
Swift
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)
|
|
}
|