Files
InvestmentTrackerApp/PortfolioJournal/ViewModels/SourceDetailViewModel.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

334 lines
10 KiB
Swift

import Foundation
import Combine
import CoreData
@MainActor
class SourceDetailViewModel: ObservableObject {
// MARK: - Published Properties
@Published var source: InvestmentSource
@Published var snapshots: [Snapshot] = []
@Published var metrics: InvestmentMetrics = .empty
@Published var predictions: [Prediction] = []
@Published var predictionResult: PredictionResult?
@Published var isDeleted = false
@Published var isLoading = false
@Published var showingAddSnapshot = false
@Published var showingEditSource = false
@Published var showingPaywall = false
@Published var errorMessage: String?
// MARK: - Chart Data
@Published var chartData: [(date: Date, value: Decimal)] = []
// MARK: - Dependencies
private let snapshotRepository: SnapshotRepository
private let sourceRepository: InvestmentSourceRepository
private let calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
private let iapService: IAPService
private var cancellables = Set<AnyCancellable>()
private var isRefreshing = false
private var refreshQueued = false
private var refreshTask: Task<Void, Never>?
private let sourceName: String
// MARK: - Initialization
init(
source: InvestmentSource,
snapshotRepository: SnapshotRepository? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
calculationService: CalculationService? = nil,
predictionEngine: PredictionEngine? = nil,
iapService: IAPService
) {
self.source = source
self.sourceName = source.name
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.calculationService = calculationService ?? .shared
self.predictionEngine = predictionEngine ?? .shared
self.freemiumValidator = FreemiumValidator(iapService: iapService)
self.iapService = iapService
loadData()
setupObservers()
}
// MARK: - Setup
private func setupObservers() {
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] notification in
guard let self,
self.isRelevantChange(notification) else { return }
self.refreshData()
}
.store(in: &cancellables)
iapService.$isPremium
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshData()
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
isLoading = true
refreshData()
isLoading = false
}
func refreshData() {
refreshQueued = true
guard !isRefreshing else { return }
isRefreshing = true
// Cancel any previous refresh task to avoid stale updates
refreshTask?.cancel()
refreshTask = Task { [weak self] in
guard let self else { return }
guard !self.isDeleted, !self.source.isDeleted, self.source.managedObjectContext != nil else {
self.isDeleted = true
self.isRefreshing = false
return
}
while self.refreshQueued && !Task.isCancelled {
self.refreshQueued = false
// Fetch snapshots (filtered by freemium limits)
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
// Check for cancellation before updating UI
guard !Task.isCancelled else { break }
// Performance: Only update if data actually changed
let snapshotsChanged = filteredSnapshots.count != self.snapshots.count ||
filteredSnapshots.first?.date != self.snapshots.first?.date
if snapshotsChanged {
self.snapshots = filteredSnapshots
// Calculate metrics
self.metrics = calculationService.calculateMetrics(for: filteredSnapshots)
// Performance: Pre-sort once and reuse
let sortedSnapshots = filteredSnapshots.sorted { $0.date < $1.date }
// Prepare chart data - avoid creating new array if possible
self.chartData = sortedSnapshots.map { (date: $0.date, value: $0.decimalValue) }
// Calculate predictions if premium - only if we have enough data
if freemiumValidator.canViewPredictions() && sortedSnapshots.count >= 3 {
self.predictionResult = predictionEngine.predict(snapshots: sortedSnapshots)
self.predictions = self.predictionResult?.predictions ?? []
} else {
self.predictions = []
self.predictionResult = nil
}
}
}
self.isRefreshing = false
}
}
/// Cancel any ongoing background tasks - call when view disappears
func cancelPendingTasks() {
refreshTask?.cancel()
refreshTask = nil
}
// MARK: - Snapshot Actions
func addSnapshot(date: Date, value: Decimal, contribution: Decimal?, notes: String?) {
snapshotRepository.createSnapshot(
for: source,
date: date,
value: value,
contribution: contribution,
notes: notes
)
// Reschedule notification
NotificationService.shared.scheduleReminder(for: source)
showingAddSnapshot = false
refreshData()
}
func deleteSnapshot(_ snapshot: Snapshot) {
snapshotRepository.deleteSnapshot(snapshot)
refreshData()
}
func deleteSnapshot(at offsets: IndexSet) {
snapshotRepository.deleteSnapshot(at: offsets, from: snapshots)
refreshData()
}
// MARK: - Source Actions
func updateSource(
name: String,
category: Category,
frequency: NotificationFrequency,
customMonths: Int
) {
sourceRepository.updateSource(
source,
name: name,
category: category,
notificationFrequency: frequency,
customFrequencyMonths: customMonths
)
// Update notification
NotificationService.shared.scheduleReminder(for: source)
showingEditSource = false
}
func deleteSource() {
guard !isDeleted else { return }
// Mark deleted first so the view can dismiss before any further access.
isDeleted = true
snapshots = []
chartData = []
predictionResult = nil
// Cancel any pending notifications for this source
NotificationService.shared.cancelReminder(for: source)
// Delete the source using the existing repository
sourceRepository.deleteSource(source)
}
// MARK: - Predictions
func showPredictions() {
// Predictions are already loaded; free users get the paywall instead.
if !freemiumValidator.canViewPredictions() {
showingPaywall = true
}
}
// MARK: - Computed Properties
var currentValue: Decimal {
source.latestValue
}
var formattedCurrentValue: String {
currentValue.currencyString
}
var safeSourceName: String {
if source.isDeleted || source.managedObjectContext == nil {
return sourceName
}
return source.name
}
var totalReturn: Decimal {
metrics.absoluteReturn
}
var formattedTotalReturn: String {
metrics.formattedAbsoluteReturn
}
var percentageReturn: Decimal {
metrics.percentageReturn
}
var formattedPercentageReturn: String {
metrics.formattedPercentageReturn
}
var isPositiveReturn: Bool {
totalReturn >= 0
}
var categoryName: String {
source.category?.name ?? "Uncategorized"
}
var categoryColor: String {
source.category?.colorHex ?? "#3B82F6"
}
var lastUpdated: String {
source.latestSnapshot?.date.friendlyDescription ?? "Never"
}
var snapshotCount: Int {
snapshots.count
}
var hasEnoughDataForPredictions: Bool {
snapshots.count >= 3
}
var canViewPredictions: Bool {
freemiumValidator.canViewPredictions()
}
var isHistoryLimited: Bool {
!freemiumValidator.isPremium
}
var hiddenSnapshotCount: Int {
// For free users, count how many snapshots are hidden due to the 12-month limit
guard !freemiumValidator.isPremium else { return 0 }
// Get all snapshots without the date filter
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
return max(0, allSnapshots.count - snapshots.count)
}
var visibleSnapshots: [Snapshot] {
// Premium users see all snapshots (already filtered by freemiumValidator in refreshData)
// Free users also see all their filtered snapshots (limited to last 12 months)
snapshots
}
private func isRelevantChange(_ notification: Notification) -> Bool {
guard let info = notification.userInfo else { return false }
let keys: [String] = [
NSInsertedObjectsKey,
NSUpdatedObjectsKey,
NSDeletedObjectsKey,
NSRefreshedObjectsKey
]
for key in keys {
if let objects = info[key] as? Set<NSManagedObject> {
if objects.contains(where: { obj in
if let snapshot = obj as? Snapshot {
return snapshot.source?.id == source.id
}
if let investmentSource = obj as? InvestmentSource {
return investmentSource.id == source.id
}
return false
}) {
return true
}
}
}
return false
}
}