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
214 lines
6.2 KiB
Swift
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 = []
|
|
}
|
|
}
|