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 transactions: [Transaction] = [] @Published var isLoading = false @Published var showingAddSnapshot = false @Published var showingEditSource = false @Published var showingPaywall = false @Published var showingAddTransaction = 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 transactionRepository: TransactionRepository private let calculationService: CalculationService private let predictionEngine: PredictionEngine private let freemiumValidator: FreemiumValidator private let iapService: IAPService private var cancellables = Set() private var isRefreshing = false private var refreshQueued = false // MARK: - Initialization init( source: InvestmentSource, snapshotRepository: SnapshotRepository? = nil, sourceRepository: InvestmentSourceRepository? = nil, transactionRepository: TransactionRepository? = nil, calculationService: CalculationService? = nil, predictionEngine: PredictionEngine? = nil, iapService: IAPService ) { self.source = source self.snapshotRepository = snapshotRepository ?? SnapshotRepository() self.sourceRepository = sourceRepository ?? InvestmentSourceRepository() self.transactionRepository = transactionRepository ?? TransactionRepository() 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 FirebaseService.shared.logScreenView(screenName: "SourceDetail") } func refreshData() { refreshQueued = true guard !isRefreshing else { return } isRefreshing = true Task { [weak self] in guard let self else { return } while self.refreshQueued { self.refreshQueued = false // Fetch snapshots (filtered by freemium limits) let allSnapshots = snapshotRepository.fetchSnapshots(for: source) let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots) // 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 } } // Transactions update independently self.transactions = transactionRepository.fetchTransactions(for: source) } self.isRefreshing = false } } // 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) // Log analytics FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value) showingAddSnapshot = false refreshData() } func deleteSnapshot(_ snapshot: Snapshot) { snapshotRepository.deleteSnapshot(snapshot) refreshData() } func deleteSnapshot(at offsets: IndexSet) { snapshotRepository.deleteSnapshot(at: offsets, from: snapshots) refreshData() } // MARK: - Transaction Actions func addTransaction( type: TransactionType, date: Date, shares: Decimal?, price: Decimal?, amount: Decimal?, notes: String? ) { transactionRepository.createTransaction( source: source, type: type, date: date, shares: shares, price: price, amount: amount, notes: notes ) refreshData() } func deleteTransaction(_ transaction: Transaction) { transactionRepository.deleteTransaction(transaction) 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 } // MARK: - Predictions func showPredictions() { if freemiumValidator.canViewPredictions() { // Already loaded, just navigate FirebaseService.shared.logPredictionViewed( algorithm: predictionResult?.algorithm.rawValue ?? "unknown" ) } else { showingPaywall = true FirebaseService.shared.logPaywallShown(trigger: "predictions") } } // MARK: - Computed Properties var currentValue: Decimal { source.latestValue } var formattedCurrentValue: String { currentValue.currencyString } 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 && hiddenSnapshotCount > 0 } var hiddenSnapshotCount: Int { guard let limit = snapshotDisplayLimit else { return 0 } return max(0, snapshots.count - min(limit, snapshots.count)) } var snapshotDisplayLimit: Int? { freemiumValidator.isPremium ? nil : 10 } var visibleSnapshots: [Snapshot] { guard let limit = snapshotDisplayLimit else { return snapshots } return Array(snapshots.prefix(limit)) } 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 { 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 } }