import Foundation import Combine import CoreData @MainActor class SourceListViewModel: ObservableObject { // MARK: - Published Properties @Published var sources: [InvestmentSource] = [] @Published var categories: [Category] = [] @Published var selectedCategory: Category? @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() // 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), $selectedCategory, $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 FirebaseService.shared.logScreenView(screenName: "SourceList") } private func filterAndSortSources(_ allSources: [InvestmentSource]) { var filtered = allSources if !showAllAccounts, let account = selectedAccount { filtered = filtered.filter { $0.account?.id == account.id } } // Filter by category if let category = selectedCategory { filtered = filtered.filter { $0.category?.id == category.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 FirebaseService.shared.logPaywallShown(trigger: "source_limit") } } 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) // Log analytics FirebaseService.shared.logSourceAdded( categoryName: category.name, sourceCount: sourceRepository.sourceCount ) showingAddSource = false } func deleteSource(_ source: InvestmentSource) { let categoryName = source.category?.name ?? "Unknown" // Cancel notifications NotificationService.shared.cancelReminder(for: source) // Delete source sourceRepository.deleteSource(source) // Log analytics FirebaseService.shared.logSourceDeleted(categoryName: categoryName) } 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 sourcesByCategory: [Category: [InvestmentSource]] { Dictionary(grouping: sources) { $0.category ?? categories.first! } } var isEmpty: Bool { sources.isEmpty && searchText.isEmpty && selectedCategory == nil } var isFiltered: Bool { !searchText.isEmpty || selectedCategory != nil } // MARK: - Category Filter func selectCategory(_ category: Category?) { selectedCategory = category } func clearFilters() { searchText = "" selectedCategory = nil } }