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

214 lines
6.2 KiB
Swift

import Foundation
import Combine
import CoreData
@MainActor
class SourceListViewModel: ObservableObject {
// MARK: - Published Properties
@Published var sources: [InvestmentSource] = []
@Published var categories: [Category] = []
@Published var selectedCategoryIds: Set<UUID> = []
@Published var searchText = ""
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var isLoading = false
@Published var showingAddSource = false
@Published var showingPaywall = false
@Published var errorMessage: String?
// MARK: - Dependencies
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil,
iapService: IAPService
) {
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Performance: Update categories separately (less frequent)
categoryRepository.$categories
.receive(on: DispatchQueue.main)
.sink { [weak self] categories in
self?.categories = categories
}
.store(in: &cancellables)
// Performance: Combine all filter-triggering publishers into one stream
// This prevents multiple rapid filter operations when state changes
Publishers.CombineLatest4(
sourceRepository.$sources,
$searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main),
$selectedCategoryIds,
$selectedAccount
)
.combineLatest($showAllAccounts)
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.receive(on: DispatchQueue.main)
.sink { [weak self] combined, _ in
let (sources, _, _, _) = combined
self?.filterAndSortSources(sources)
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
isLoading = true
categoryRepository.createDefaultCategoriesIfNeeded()
sourceRepository.fetchSources()
categoryRepository.fetchCategories()
isLoading = false
}
private func filterAndSortSources(_ allSources: [InvestmentSource]) {
var filtered = allSources
let selectedAccountId = selectedAccount?.safeId
if !showAllAccounts, let selectedAccountId {
filtered = filtered.filter { $0.account?.id == selectedAccountId }
}
// Filter by category (multi-select)
if !selectedCategoryIds.isEmpty {
filtered = filtered.filter {
guard let id = $0.category?.id else { return false }
return selectedCategoryIds.contains(id)
}
}
// Filter by search text
if !searchText.isEmpty {
filtered = filtered.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.category?.name.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
// Sort by value descending
sources = filtered.sorted { $0.latestValue > $1.latestValue }
}
// MARK: - Actions
func addSourceTapped() {
if freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount) {
showingAddSource = true
} else {
showingPaywall = true
}
}
func createSource(
name: String,
category: Category,
frequency: NotificationFrequency,
customMonths: Int = 1,
account: Account? = nil
) {
let source = sourceRepository.createSource(
name: name,
category: category,
notificationFrequency: frequency,
customFrequencyMonths: customMonths,
account: account
)
// Schedule notification
NotificationService.shared.scheduleReminder(for: source)
showingAddSource = false
}
func deleteSource(_ source: InvestmentSource) {
// Cancel notifications
NotificationService.shared.cancelReminder(for: source)
// Delete source
sourceRepository.deleteSource(source)
}
func deleteSource(at offsets: IndexSet) {
for index in offsets {
guard index < sources.count else { continue }
deleteSource(sources[index])
}
}
func toggleSourceActive(_ source: InvestmentSource) {
sourceRepository.toggleActive(source)
if source.isActive {
NotificationService.shared.scheduleReminder(for: source)
} else {
NotificationService.shared.cancelReminder(for: source)
}
}
// MARK: - Computed Properties
var canAddSource: Bool {
freemiumValidator.canAddSource(currentCount: sourceRepository.sourceCount)
}
var remainingSources: Int {
freemiumValidator.remainingSources(currentCount: sourceRepository.sourceCount)
}
var sourceLimitReached: Bool {
!canAddSource
}
var totalValue: Decimal {
sources.reduce(Decimal.zero) { $0 + $1.latestValue }
}
var formattedTotalValue: String {
totalValue.currencyString
}
var isEmpty: Bool {
sources.isEmpty && searchText.isEmpty && selectedCategoryIds.isEmpty
}
var isFiltered: Bool {
!searchText.isEmpty || !selectedCategoryIds.isEmpty
}
// MARK: - Category Filter
func selectCategory(_ category: Category?) {
guard let category else {
selectedCategoryIds = []
return
}
if selectedCategoryIds.contains(category.id) {
selectedCategoryIds.remove(category.id)
} else {
selectedCategoryIds.insert(category.id)
}
}
func clearFilters() {
searchText = ""
selectedCategoryIds = []
}
}