initial version

This commit is contained in:
alexandrev-tibco
2026-01-15 09:24:06 +01:00
parent bab350dd22
commit 7988257399
139 changed files with 13149 additions and 3233 deletions
@@ -0,0 +1,705 @@
import Foundation
import Combine
@MainActor
class ChartsViewModel: ObservableObject {
// MARK: - Chart Types
enum ChartType: String, CaseIterable, Identifiable {
case evolution = "Evolution"
case allocation = "Allocation"
case performance = "Performance"
case contributions = "Contributions"
case rollingReturn = "Rolling 12M"
case riskReturn = "Risk vs Return"
case cashflow = "Net vs Contributions"
case drawdown = "Drawdown"
case volatility = "Volatility"
case prediction = "Prediction"
var id: String { rawValue }
var icon: String {
switch self {
case .evolution: return "chart.line.uptrend.xyaxis"
case .allocation: return "chart.pie.fill"
case .performance: return "chart.bar.fill"
case .contributions: return "tray.and.arrow.down.fill"
case .rollingReturn: return "arrow.triangle.2.circlepath"
case .riskReturn: return "dot.square"
case .cashflow: return "chart.bar.xaxis"
case .drawdown: return "arrow.down.right.circle"
case .volatility: return "waveform.path.ecg"
case .prediction: return "wand.and.stars"
}
}
var isPremium: Bool {
switch self {
case .evolution:
return false
case .allocation, .performance, .contributions, .rollingReturn, .riskReturn, .cashflow,
.drawdown, .volatility, .prediction:
return true
}
}
var description: String {
switch self {
case .evolution:
return "Track your portfolio value over time"
case .allocation:
return "See how your investments are distributed"
case .performance:
return "Compare returns across categories"
case .contributions:
return "Review monthly inflows over time"
case .rollingReturn:
return "See rolling 12-month performance"
case .riskReturn:
return "Compare volatility vs return"
case .cashflow:
return "Compare growth vs contributions"
case .drawdown:
return "Analyze declines from peak values"
case .volatility:
return "Understand investment risk levels"
case .prediction:
return "View 12-month forecasts"
}
}
}
func availableChartTypes(calmModeEnabled: Bool) -> [ChartType] {
let types: [ChartType] = calmModeEnabled
? [.evolution, .allocation, .performance, .contributions]
: ChartType.allCases
if !types.contains(selectedChartType) {
selectedChartType = .evolution
}
return types
}
// MARK: - Published Properties
@Published var selectedChartType: ChartType = .evolution
@Published var selectedCategory: Category?
@Published var selectedTimeRange: TimeRange = .year
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var evolutionData: [(date: Date, value: Decimal)] = []
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
@Published var allocationData: [(category: String, value: Decimal, color: String)] = []
@Published var performanceData: [(category: String, cagr: Double, color: String)] = []
@Published var contributionsData: [(date: Date, amount: Decimal)] = []
@Published var rollingReturnData: [(date: Date, value: Double)] = []
@Published var riskReturnData: [(category: String, cagr: Double, volatility: Double, color: String)] = []
@Published var cashflowData: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
@Published var drawdownData: [(date: Date, drawdown: Double)] = []
@Published var volatilityData: [(date: Date, volatility: Double)] = []
@Published var predictionData: [Prediction] = []
@Published var isLoading = false
@Published var showingPaywall = false
@Published private var predictionMonthsAhead = 12
// MARK: - Time Range
enum TimeRange: String, CaseIterable, Identifiable {
case month = "1M"
case quarter = "3M"
case halfYear = "6M"
case year = "1Y"
case all = "All"
var id: String { rawValue }
var months: Int? {
switch self {
case .month: return 1
case .quarter: return 3
case .halfYear: return 6
case .year: return 12
case .all: return nil
}
}
}
// MARK: - Dependencies
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let snapshotRepository: SnapshotRepository
private let calculationService: CalculationService
private let predictionEngine: PredictionEngine
private let freemiumValidator: FreemiumValidator
private let maxHistoryMonths = 60
private let maxStackedCategories = 6
private let maxChartPoints = 500
private var cancellables = Set<AnyCancellable>()
private var allCategories: [Category] {
categoryRepository.categories
}
// MARK: - Performance: Caching and State
private var lastChartType: ChartType?
private var lastTimeRange: TimeRange?
private var lastCategoryId: UUID?
private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true
private var cachedSnapshots: [Snapshot]?
private var cachedSnapshotsBySource: [UUID: [Snapshot]]?
private var isUpdateInProgress = false
// MARK: - Initialization
init(
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil,
snapshotRepository: SnapshotRepository? = nil,
calculationService: CalculationService? = nil,
predictionEngine: PredictionEngine? = nil,
iapService: IAPService
) {
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.calculationService = calculationService ?? .shared
self.predictionEngine = predictionEngine ?? .shared
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Performance: Combine all selection changes into a single debounced stream
// This prevents multiple rapid updates when switching between views
Publishers.CombineLatest4($selectedChartType, $selectedCategory, $selectedTimeRange, $selectedAccount)
.combineLatest($showAllAccounts)
.debounce(for: .milliseconds(150), scheduler: DispatchQueue.main)
.sink { [weak self] combined, showAll in
guard let self else { return }
let (chartType, category, timeRange, _) = combined
// Performance: Skip update if nothing meaningful changed
let hasChanges = self.lastChartType != chartType ||
self.lastTimeRange != timeRange ||
self.lastCategoryId != category?.id ||
self.lastAccountId != self.selectedAccount?.id ||
self.lastShowAllAccounts != showAll
if hasChanges {
self.lastChartType = chartType
self.lastTimeRange = timeRange
self.lastCategoryId = category?.id
self.lastAccountId = self.selectedAccount?.id
self.lastShowAllAccounts = showAll
self.cachedSnapshots = nil // Invalidate cache on meaningful changes
self.cachedSnapshotsBySource = nil
self.updateChartData(chartType: chartType, category: category, timeRange: timeRange)
}
}
.store(in: &cancellables)
}
// MARK: - Data Loading
func loadData() {
updateChartData(
chartType: selectedChartType,
category: selectedCategory,
timeRange: selectedTimeRange
)
FirebaseService.shared.logScreenView(screenName: "Charts")
}
func selectChart(_ chartType: ChartType) {
if chartType.isPremium && !freemiumValidator.isPremium {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "advanced_charts")
return
}
selectedChartType = chartType
FirebaseService.shared.logChartViewed(
chartType: chartType.rawValue,
isPremium: chartType.isPremium
)
}
private func updateChartData(chartType: ChartType, category: Category?, timeRange: TimeRange) {
// Performance: Prevent re-entrancy
guard !isUpdateInProgress else { return }
isUpdateInProgress = true
isLoading = true
defer {
isLoading = false
isUpdateInProgress = false
}
let sources: [InvestmentSource]
if let category = category {
sources = sourceRepository.fetchSources(for: category).filter { shouldIncludeSource($0) }
} else {
sources = sourceRepository.sources.filter { shouldIncludeSource($0) }
}
let monthsLimit = timeRange.months ?? maxHistoryMonths
let sourceIds = sources.compactMap { $0.id }
// Performance: Reuse cached snapshots when possible
var snapshots: [Snapshot]
if let cached = cachedSnapshots {
snapshots = cached
} else {
snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: monthsLimit
)
snapshots = freemiumValidator.filterSnapshots(snapshots)
cachedSnapshots = snapshots
}
// Performance: Cache snapshotsBySource
let snapshotsBySource: [UUID: [Snapshot]]
if let cached = cachedSnapshotsBySource {
snapshotsBySource = cached
} else {
var grouped: [UUID: [Snapshot]] = [:]
grouped.reserveCapacity(sources.count)
for snapshot in snapshots {
guard let id = snapshot.source?.id else { continue }
grouped[id, default: []].append(snapshot)
}
snapshotsBySource = grouped
cachedSnapshotsBySource = grouped
}
// Performance: Only calculate data for the selected chart type
switch chartType {
case .evolution:
calculateEvolutionData(from: snapshots)
let categoriesForChart = categoriesForStackedChart(
sources: sources,
selectedCategory: selectedCategory
)
calculateCategoryEvolutionData(from: snapshots, categories: categoriesForChart)
case .allocation:
calculateAllocationData(for: sources)
case .performance:
calculatePerformanceData(for: sources, snapshotsBySource: snapshotsBySource)
case .contributions:
calculateContributionsData(from: snapshots)
case .rollingReturn:
calculateRollingReturnData(from: snapshots)
case .riskReturn:
calculateRiskReturnData(for: sources, snapshotsBySource: snapshotsBySource)
case .cashflow:
calculateCashflowData(from: snapshots)
case .drawdown:
calculateDrawdownData(from: snapshots)
case .volatility:
calculateVolatilityData(from: snapshots)
case .prediction:
calculatePredictionData(from: snapshots)
}
if let selected = selectedCategory,
!availableCategories(for: chartType, sources: sources).contains(where: { $0.id == selected.id }) {
selectedCategory = nil
}
}
func availableCategories(
for chartType: ChartType,
sources: [InvestmentSource]? = nil
) -> [Category] {
let relevantSources = sources ?? sourceRepository.sources.filter { shouldIncludeSource($0) }
let categoriesWithData = Set(relevantSources.compactMap { $0.category?.id })
let filtered = allCategories.filter { categoriesWithData.contains($0.id) }
switch chartType {
case .evolution, .prediction:
return filtered
default:
return []
}
}
private func shouldIncludeSource(_ source: InvestmentSource) -> Bool {
if showAllAccounts || selectedAccount == nil {
return true
}
return source.account?.id == selectedAccount?.id
}
private func categoriesForStackedChart(
sources: [InvestmentSource],
selectedCategory: Category?
) -> [Category] {
var totals: [UUID: Decimal] = [:]
for source in sources {
guard let categoryId = source.category?.id else { continue }
totals[categoryId, default: 0] += source.latestValue
}
var topCategoryIds = Set(
totals.sorted { $0.value > $1.value }
.prefix(maxStackedCategories)
.map { $0.key }
)
if let selectedCategory {
topCategoryIds.insert(selectedCategory.id)
}
return categoryRepository.categories.filter { topCategoryIds.contains($0.id) }
}
private func downsampleSeries(
_ data: [(date: Date, value: Decimal)],
maxPoints: Int
) -> [(date: Date, value: Decimal)] {
guard data.count > maxPoints, maxPoints > 0 else { return data }
let bucketSize = max(1, Int(ceil(Double(data.count) / Double(maxPoints))))
var sampled: [(date: Date, value: Decimal)] = []
sampled.reserveCapacity(maxPoints)
var index = 0
while index < data.count {
let end = min(index + bucketSize, data.count)
let bucket = data[index..<end]
if let last = bucket.last {
sampled.append(last)
}
index += bucketSize
}
return sampled
}
private func downsampleDates(_ dates: [Date], maxPoints: Int) -> [Date] {
guard dates.count > maxPoints, maxPoints > 0 else { return dates }
let bucketSize = max(1, Int(ceil(Double(dates.count) / Double(maxPoints))))
var sampled: [Date] = []
sampled.reserveCapacity(maxPoints)
var index = 0
while index < dates.count {
let end = min(index + bucketSize, dates.count)
let bucket = dates[index..<end]
if let last = bucket.last {
sampled.append(last)
}
index += bucketSize
}
return sampled
}
// MARK: - Chart Calculations
private func calculateEvolutionData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
var dateValues: [Date: Decimal] = [:]
for snapshot in sortedSnapshots {
let day = Calendar.current.startOfDay(for: snapshot.date)
dateValues[day, default: 0] += snapshot.decimalValue
}
let series = dateValues
.map { (date: $0.key, value: $0.value) }
.sorted { $0.date < $1.date }
evolutionData = downsampleSeries(series, maxPoints: maxChartPoints)
}
private func calculateCategoryEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let categoriesWithData = Set(sortedSnapshots.compactMap { $0.source?.category?.id })
let filteredCategories = categories.filter { categoriesWithData.contains($0.id) }
let snapshotsByDay = Dictionary(grouping: sortedSnapshots) {
Calendar.current.startOfDay(for: $0.date)
}
let uniqueDates = downsampleDates(snapshotsByDay.keys.sorted(), maxPoints: maxChartPoints)
var latestBySource: [UUID: Snapshot] = [:]
var points: [CategoryEvolutionPoint] = []
for date in uniqueDates {
if let daySnapshots = snapshotsByDay[date] {
for snapshot in daySnapshots {
guard let sourceId = snapshot.source?.id else { continue }
latestBySource[sourceId] = snapshot
}
}
var valuesByCategory: [UUID: Decimal] = [:]
for snapshot in latestBySource.values {
guard let category = snapshot.source?.category else { continue }
valuesByCategory[category.id, default: 0] += snapshot.decimalValue
}
for category in filteredCategories {
let value = valuesByCategory[category.id] ?? 0
points.append(CategoryEvolutionPoint(
date: date,
categoryName: category.name,
colorHex: category.colorHex,
value: value
))
}
}
categoryEvolutionData = points
}
private func calculateAllocationData(for sources: [InvestmentSource]) {
let categories = categoryRepository.categories
let valuesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
allocationData = categories.compactMap { category in
let categorySources = valuesByCategory[category.id] ?? []
let categoryValue = categorySources.reduce(Decimal.zero) { $0 + $1.latestValue }
guard categoryValue > 0 else { return nil }
return (
category: category.name,
value: categoryValue,
color: category.colorHex
)
}.sorted { $0.value > $1.value }
}
private func calculatePerformanceData(
for sources: [InvestmentSource],
snapshotsBySource: [UUID: [Snapshot]]
) {
let categories = categoryRepository.categories
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
performanceData = categories.compactMap { category in
let categorySources = sourcesByCategory[category.id] ?? []
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
let id = source.id
return snapshotsBySource[id]
}.flatMap { $0 }
guard snapshots.count >= 2 else { return nil }
let metrics = calculationService.calculateMetrics(for: snapshots)
return (
category: category.name,
cagr: metrics.cagr,
color: category.colorHex
)
}.sorted { $0.cagr > $1.cagr }
}
private func calculateContributionsData(from snapshots: [Snapshot]) {
let grouped = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
contributionsData = grouped.map { date, items in
let total = items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
return (date: date, amount: total)
}
.sorted { $0.date < $1.date }
}
private func calculateRollingReturnData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
guard monthlyTotals.count >= 13 else {
rollingReturnData = []
return
}
var returns: [(date: Date, value: Double)] = []
for index in 12..<monthlyTotals.count {
let current = monthlyTotals[index]
let base = monthlyTotals[index - 12]
guard base.totalValue > 0 else { continue }
let change = current.totalValue - base.totalValue
let percent = NSDecimalNumber(decimal: change / base.totalValue).doubleValue * 100
returns.append((date: current.date, value: percent))
}
rollingReturnData = returns
}
private func calculateRiskReturnData(
for sources: [InvestmentSource],
snapshotsBySource: [UUID: [Snapshot]]
) {
let categories = categoryRepository.categories
let sourcesByCategory = Dictionary(grouping: sources) { $0.category?.id ?? UUID() }
riskReturnData = categories.compactMap { category in
let categorySources = sourcesByCategory[category.id] ?? []
let snapshots = categorySources.compactMap { source -> [Snapshot]? in
let id = source.id
return snapshotsBySource[id]
}.flatMap { $0 }
guard snapshots.count >= 3 else { return nil }
let metrics = calculationService.calculateMetrics(for: snapshots)
return (
category: category.name,
cagr: metrics.cagr,
volatility: metrics.volatility,
color: category.colorHex
)
}.sorted { $0.cagr > $1.cagr }
}
private func calculateCashflowData(from snapshots: [Snapshot]) {
let monthlyTotals = monthlyTotals(from: snapshots)
let contributionsByMonth = Dictionary(grouping: snapshots) { $0.date.startOfMonth }
.mapValues { items in
items.reduce(Decimal.zero) { $0 + $1.decimalContribution }
}
var data: [(date: Date, contributions: Decimal, netPerformance: Decimal)] = []
for index in 0..<monthlyTotals.count {
let current = monthlyTotals[index]
let previousTotal = index > 0 ? monthlyTotals[index - 1].totalValue : 0
let contributions = contributionsByMonth[current.date] ?? 0
let netPerformance = current.totalValue - previousTotal - contributions
data.append((date: current.date, contributions: contributions, netPerformance: netPerformance))
}
cashflowData = data
}
private func calculateDrawdownData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard !sortedSnapshots.isEmpty else {
drawdownData = []
return
}
var peak = sortedSnapshots.first!.decimalValue
var data: [(date: Date, drawdown: Double)] = []
for snapshot in sortedSnapshots {
let value = snapshot.decimalValue
if value > peak {
peak = value
}
let drawdown = peak > 0
? NSDecimalNumber(decimal: (peak - value) / peak).doubleValue * 100
: 0
data.append((date: snapshot.date, drawdown: -drawdown))
}
drawdownData = data
}
private func calculateVolatilityData(from snapshots: [Snapshot]) {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
guard sortedSnapshots.count >= 3 else {
volatilityData = []
return
}
var data: [(date: Date, volatility: Double)] = []
let windowSize = 3
for i in windowSize..<sortedSnapshots.count {
let window = Array(sortedSnapshots[(i - windowSize)..<i])
let values = window.map { NSDecimalNumber(decimal: $0.decimalValue).doubleValue }
let mean = values.reduce(0, +) / Double(values.count)
let variance = values.map { pow($0 - mean, 2) }.reduce(0, +) / Double(values.count)
let stdDev = sqrt(variance)
let volatility = mean > 0 ? (stdDev / mean) * 100 : 0
data.append((date: sortedSnapshots[i].date, volatility: volatility))
}
volatilityData = data
}
private func calculatePredictionData(from snapshots: [Snapshot]) {
guard freemiumValidator.canViewPredictions() else {
predictionData = []
return
}
let result = predictionEngine.predict(snapshots: snapshots, monthsAhead: predictionMonthsAhead)
predictionData = result.predictions
}
private func monthlyTotals(from snapshots: [Snapshot]) -> [(date: Date, totalValue: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let months = Array(Set(sortedSnapshots.map { $0.date.startOfMonth })).sorted()
guard !months.isEmpty else { return [] }
var snapshotsBySource: [UUID: [Snapshot]] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(snapshot)
}
var indices: [UUID: Int] = [:]
var totals: [(date: Date, totalValue: Decimal)] = []
for (index, month) in months.enumerated() {
let nextMonth = index + 1 < months.count ? months[index + 1] : Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: Snapshot?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextMonth {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
total += latest?.decimalValue ?? 0
}
totals.append((date: month, totalValue: total))
}
return totals
}
func updatePredictionTargetDate(_ goals: [Goal]) {
let futureGoalDates = goals.compactMap { $0.targetDate }.filter { $0 > Date() }
guard let latestGoalDate = futureGoalDates.max(),
let lastSnapshotDate = evolutionData.last?.date else {
predictionMonthsAhead = 12
return
}
let months = max(1, lastSnapshotDate.startOfMonth.monthsBetween(latestGoalDate.startOfMonth))
predictionMonthsAhead = max(12, months)
if selectedChartType == .prediction {
updateChartData(chartType: selectedChartType, category: selectedCategory, timeRange: selectedTimeRange)
}
}
// MARK: - Computed Properties
var categories: [Category] {
categoryRepository.categories
}
var availableChartTypes: [ChartType] {
ChartType.allCases
}
var isPremium: Bool {
freemiumValidator.isPremium
}
var hasData: Bool {
!sourceRepository.sources.filter { shouldIncludeSource($0) }.isEmpty
}
}
@@ -0,0 +1,443 @@
import Foundation
import Combine
import CoreData
@MainActor
class DashboardViewModel: ObservableObject {
// MARK: - Published Properties
@Published var portfolioSummary: PortfolioSummary = .empty
@Published var monthlySummary: MonthlySummary = .empty
@Published var categoryMetrics: [CategoryMetrics] = []
@Published var categoryEvolutionData: [CategoryEvolutionPoint] = []
@Published var recentSnapshots: [Snapshot] = []
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
@Published var latestPortfolioChange: PortfolioChange = .empty
@Published var isLoading = false
@Published var errorMessage: String?
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
// MARK: - Chart Data
@Published var evolutionData: [(date: Date, value: Decimal)] = []
// MARK: - Dependencies
private let categoryRepository: CategoryRepository
private let sourceRepository: InvestmentSourceRepository
private let snapshotRepository: SnapshotRepository
private let calculationService: CalculationService
private var cancellables = Set<AnyCancellable>()
private var isRefreshing = false
private var refreshQueued = false
private let maxHistoryMonths = 60
// MARK: - Performance: Caching
private var cachedFilteredSources: [InvestmentSource]?
private var cachedSourcesHash: Int = 0
private var lastAccountId: UUID?
private var lastShowAllAccounts: Bool = true
// MARK: - Initialization
init(
categoryRepository: CategoryRepository? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
snapshotRepository: SnapshotRepository? = nil,
calculationService: CalculationService? = nil
) {
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
self.calculationService = calculationService ?? .shared
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
// Performance: Combine multiple publishers to reduce redundant refresh calls
// Use dropFirst to avoid initial trigger, and debounce to coalesce rapid changes
Publishers.Merge(
categoryRepository.$categories.map { _ in () },
sourceRepository.$sources.map { _ in () }
)
.dropFirst(2) // Skip initial values from both publishers
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.invalidateCache()
self?.refreshData()
}
.store(in: &cancellables)
// Observe Core Data changes with coalescing
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.compactMap { [weak self] notification -> Void? in
guard let self, self.isRelevantChange(notification) else { return nil }
return ()
}
.sink { [weak self] _ in
self?.invalidateCache()
self?.refreshData()
}
.store(in: &cancellables)
}
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: { $0 is Snapshot || $0 is InvestmentSource || $0 is Category }) {
return true
}
}
}
return false
}
// MARK: - Data Loading
func loadData() {
isLoading = true
errorMessage = nil
queueRefresh(updateLoadingFlag: true)
}
func refreshData() {
queueRefresh(updateLoadingFlag: false)
}
private func queueRefresh(updateLoadingFlag: Bool) {
refreshQueued = true
if updateLoadingFlag {
isLoading = true
}
guard !isRefreshing else { return }
isRefreshing = true
Task { [weak self] in
guard let self else { return }
while self.refreshQueued {
self.refreshQueued = false
await self.refreshAllData()
}
self.isRefreshing = false
if updateLoadingFlag {
self.isLoading = false
}
}
}
private func refreshAllData() async {
let categories = categoryRepository.categories
let sources = filteredSources()
let allSnapshots = filteredSnapshots(for: sources)
// Calculate portfolio summary
portfolioSummary = calculationService.calculatePortfolioSummary(
from: sources,
snapshots: allSnapshots
)
monthlySummary = calculationService.calculateMonthlySummary(
sources: sources,
snapshots: allSnapshots
)
// Calculate category metrics
categoryMetrics = calculationService.calculateCategoryMetrics(
for: categories,
sources: sources,
totalPortfolioValue: portfolioSummary.totalValue
).sorted { $0.totalValue > $1.totalValue }
// Get recent snapshots
recentSnapshots = Array(allSnapshots.prefix(10))
// Get sources needing update
let accountFilter = showAllAccounts ? nil : selectedAccount
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
// Calculate evolution data for chart
updateEvolutionData(from: allSnapshots, categories: categories)
latestPortfolioChange = calculateLatestChange(from: evolutionData)
// Log screen view
FirebaseService.shared.logScreenView(screenName: "Dashboard")
}
private func filteredSources() -> [InvestmentSource] {
// Performance: Cache filtered sources to avoid repeated filtering
let currentHash = sourceRepository.sources.count
let accountChanged = lastAccountId != selectedAccount?.id || lastShowAllAccounts != showAllAccounts
if !accountChanged && cachedFilteredSources != nil && cachedSourcesHash == currentHash {
return cachedFilteredSources!
}
lastAccountId = selectedAccount?.id
lastShowAllAccounts = showAllAccounts
cachedSourcesHash = currentHash
if showAllAccounts || selectedAccount == nil {
cachedFilteredSources = sourceRepository.sources
} else {
cachedFilteredSources = sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
}
return cachedFilteredSources!
}
/// Invalidates cached data when underlying data changes
private func invalidateCache() {
cachedFilteredSources = nil
cachedSourcesHash = 0
}
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
let sourceIds = sources.compactMap { $0.id }
return snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
}
private func updateEvolutionData(from snapshots: [Snapshot], categories: [Category]) {
let summary = calculateEvolutionSummary(from: snapshots)
let categoriesWithData = Set(summary.categoryTotals.keys)
let categoryLookup = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
evolutionData = summary.evolutionData
categoryEvolutionData = summary.categorySeries.flatMap { entry in
entry.valuesByCategory.compactMap { categoryId, value in
guard categoriesWithData.contains(categoryId),
let category = categoryLookup[categoryId] else {
return nil
}
return CategoryEvolutionPoint(
date: entry.date,
categoryName: category.name,
colorHex: category.colorHex,
value: value
)
}
}
}
private struct EvolutionSummary {
let evolutionData: [(date: Date, value: Decimal)]
let categorySeries: [(date: Date, valuesByCategory: [UUID: Decimal])]
let categoryTotals: [UUID: Decimal]
}
private func calculateEvolutionSummary(from snapshots: [Snapshot]) -> EvolutionSummary {
guard !snapshots.isEmpty else {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
// Performance: Pre-allocate capacity and use more efficient data structures
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
// Use dictionary to deduplicate dates more efficiently
var uniqueDateSet = Set<Date>()
uniqueDateSet.reserveCapacity(sortedSnapshots.count)
for snapshot in sortedSnapshots {
uniqueDateSet.insert(Calendar.current.startOfDay(for: snapshot.date))
}
let uniqueDates = uniqueDateSet.sorted()
guard !uniqueDates.isEmpty else {
return EvolutionSummary(evolutionData: [], categorySeries: [], categoryTotals: [:])
}
// Pre-allocate dictionaries with estimated capacity
var snapshotsBySource: [UUID: [(date: Date, value: Decimal, categoryId: UUID?)]] = [:]
snapshotsBySource.reserveCapacity(Set(sortedSnapshots.compactMap { $0.source?.id }).count)
var categoryTotals: [UUID: Decimal] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
let categoryId = snapshot.source?.category?.id
snapshotsBySource[sourceId, default: []].append(
(date: snapshot.date, value: snapshot.decimalValue, categoryId: categoryId)
)
if let categoryId {
categoryTotals[categoryId, default: 0] += snapshot.decimalValue
}
}
// Pre-allocate result arrays
var indices: [UUID: Int] = [:]
indices.reserveCapacity(snapshotsBySource.count)
var evolution: [(date: Date, value: Decimal)] = []
evolution.reserveCapacity(uniqueDates.count)
var series: [(date: Date, valuesByCategory: [UUID: Decimal])] = []
series.reserveCapacity(uniqueDates.count)
// Track last known value per source for carry-forward optimization
var lastValues: [UUID: (value: Decimal, categoryId: UUID?)] = [:]
lastValues.reserveCapacity(snapshotsBySource.count)
for (index, date) in uniqueDates.enumerated() {
let nextDate = index + 1 < uniqueDates.count
? uniqueDates[index + 1]
: Date.distantFuture
var total: Decimal = 0
var valuesByCategory: [UUID: Decimal] = [:]
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
let snap = sourceSnapshots[currentIndex]
lastValues[sourceId] = (value: snap.value, categoryId: snap.categoryId)
currentIndex += 1
}
indices[sourceId] = currentIndex
// Use last known value (carry-forward)
if let lastValue = lastValues[sourceId] {
total += lastValue.value
if let categoryId = lastValue.categoryId {
valuesByCategory[categoryId, default: 0] += lastValue.value
}
}
}
evolution.append((date: date, value: total))
series.append((date: date, valuesByCategory: valuesByCategory))
}
return EvolutionSummary(
evolutionData: evolution,
categorySeries: series,
categoryTotals: categoryTotals
)
}
private func calculateLatestChange(from data: [(date: Date, value: Decimal)]) -> PortfolioChange {
guard data.count >= 2 else {
return PortfolioChange(absolute: 0, percentage: 0, label: "since last update")
}
let last = data[data.count - 1]
let previous = data[data.count - 2]
let absolute = last.value - previous.value
let percentage = previous.value > 0
? NSDecimalNumber(decimal: absolute / previous.value).doubleValue * 100
: 0
return PortfolioChange(absolute: absolute, percentage: percentage, label: "since last update")
}
func goalEtaText(for goal: Goal, currentValue: Decimal) -> String? {
let target = goal.targetDecimal
let lastValue = evolutionData.last?.value ?? currentValue
if currentValue >= target {
return "Goal reached. Keep it steady."
}
guard let months = estimateMonthsToGoal(target: target, lastValue: lastValue) else {
return "Keep going. Consistency pays off."
}
if months == 0 {
return "Almost there. One more check-in."
}
let baseDate = evolutionData.last?.date ?? Date()
let estimatedDate = Calendar.current.date(byAdding: .month, value: months, to: baseDate)
if months >= 72 {
return "Long journey, strong discipline. You're building momentum."
}
if let estimatedDate = estimatedDate {
return "Estimated: \(estimatedDate.monthYearString)"
}
return "Estimated: \(months) months"
}
private func estimateMonthsToGoal(target: Decimal, lastValue: Decimal) -> Int? {
guard evolutionData.count >= 3 else { return nil }
let recent = Array(evolutionData.suffix(6))
guard let first = recent.first, let last = recent.last else { return nil }
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else { return nil }
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else { return nil }
let remaining = target - lastValue
guard remaining > 0 else { return 0 }
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
return Int(ceil(months))
}
// MARK: - Computed Properties
var hasData: Bool {
!filteredSources().isEmpty
}
var totalSourceCount: Int {
sourceRepository.sourceCount
}
var totalCategoryCount: Int {
categoryRepository.categories.count
}
var pendingUpdatesCount: Int {
sourcesNeedingUpdate.count
}
var topCategories: [CategoryMetrics] {
Array(categoryMetrics.prefix(5))
}
// MARK: - Formatting
var formattedTotalValue: String {
portfolioSummary.formattedTotalValue
}
var formattedDayChange: String {
portfolioSummary.formattedDayChange
}
var formattedMonthChange: String {
portfolioSummary.formattedMonthChange
}
var formattedYearChange: String {
portfolioSummary.formattedYearChange
}
var isDayChangePositive: Bool {
portfolioSummary.dayChange >= 0
}
var isMonthChangePositive: Bool {
portfolioSummary.monthChange >= 0
}
var isYearChangePositive: Bool {
portfolioSummary.yearChange >= 0
}
var formattedLastUpdate: String {
portfolioSummary.lastUpdated?.friendlyDescription ?? "Not yet"
}
}
@@ -0,0 +1,291 @@
import Foundation
import Combine
import CoreData
@MainActor
class GoalsViewModel: ObservableObject {
@Published var goals: [Goal] = []
@Published var totalValue: Decimal = Decimal.zero
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
private let goalRepository: GoalRepository
private let sourceRepository: InvestmentSourceRepository
private let snapshotRepository: SnapshotRepository
private let maxHistoryMonths = 60
private var cancellables = Set<AnyCancellable>()
// MARK: - Performance: Caching
private var cachedEvolutionData: [UUID: [(date: Date, value: Decimal)]] = [:]
private var cachedCompletionDates: [UUID: Date?] = [:]
private var lastSourcesHash: Int = 0
init(
goalRepository: GoalRepository = GoalRepository(),
sourceRepository: InvestmentSourceRepository = InvestmentSourceRepository(),
snapshotRepository: SnapshotRepository = SnapshotRepository()
) {
self.goalRepository = goalRepository
self.sourceRepository = sourceRepository
self.snapshotRepository = snapshotRepository
setupObservers()
refresh()
}
private func setupObservers() {
goalRepository.$goals
.receive(on: DispatchQueue.main)
.sink { [weak self] goals in
self?.updateGoals(using: goals)
}
.store(in: &cancellables)
sourceRepository.$sources
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updateTotalValue()
}
.store(in: &cancellables)
}
func refresh() {
// Performance: Invalidate caches when refreshing
let currentHash = sourceRepository.sources.count
if currentHash != lastSourcesHash {
cachedEvolutionData.removeAll()
cachedCompletionDates.removeAll()
lastSourcesHash = currentHash
}
loadGoals()
updateGoals(using: goalRepository.goals)
}
func progress(for goal: Goal) -> Double {
let currentTotal = totalValue(for: goal)
guard goal.targetDecimal > 0 else { return 0 }
let current = min(currentTotal, goal.targetDecimal)
return NSDecimalNumber(decimal: current / goal.targetDecimal).doubleValue
}
func totalValue(for goal: Goal) -> Decimal {
if let account = goal.account {
return sourceRepository.sources
.filter { $0.account?.id == account.id }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
return totalValue
}
func paceStatus(for goal: Goal) -> GoalPaceStatus? {
guard let targetDate = goal.targetDate else { return nil }
let targetDay = targetDate.startOfDay
let actualProgress = progress(for: goal)
let startDate = goal.createdAt.startOfDay
let totalDays = max(1, startDate.daysBetween(targetDay))
if actualProgress >= 1 {
return GoalPaceStatus(
expectedProgress: 1,
delta: 0,
isBehind: false,
statusText: "Goal reached"
)
}
if let estimatedCompletionDate = estimateCompletionDate(for: goal) {
let estimatedDay = estimatedCompletionDate.startOfDay
let deltaDays = targetDay.daysBetween(estimatedDay)
let deltaPercent = min(abs(Double(deltaDays)) / Double(totalDays) * 100, 999)
let isBehind = estimatedDay > targetDay
let statusText = abs(deltaPercent) < 1
? "On track"
: isBehind
? String(format: "Behind by %.1f%%", deltaPercent)
: String(format: "Ahead by %.1f%%", deltaPercent)
return GoalPaceStatus(
expectedProgress: actualProgress,
delta: isBehind ? -deltaPercent / 100 : deltaPercent / 100,
isBehind: isBehind,
statusText: statusText
)
}
let elapsedDays = max(0, startDate.daysBetween(Date()))
let expectedProgress = min(Double(elapsedDays) / Double(totalDays), 1)
let delta = actualProgress - expectedProgress
let isOverdue = Date() > targetDay && actualProgress < 1
let isBehind = isOverdue || delta < -0.03
let deltaPercent = abs(delta) * 100
let statusText = isOverdue
? "Behind schedule • target passed"
: delta >= 0
? String(format: "Ahead by %.1f%%", deltaPercent)
: String(format: "Behind by %.1f%%", deltaPercent)
return GoalPaceStatus(
expectedProgress: expectedProgress,
delta: delta,
isBehind: isBehind,
statusText: statusText
)
}
func deleteGoal(_ goal: Goal) {
goalRepository.deleteGoal(goal)
}
private func estimateCompletionDate(for goal: Goal) -> Date? {
// Performance: Use cached completion date if available
if let cached = cachedCompletionDates[goal.id] {
return cached
}
let sources: [InvestmentSource]
if let account = goal.account {
sources = sourceRepository.sources.filter { $0.account?.id == account.id }
} else {
sources = sourceRepository.sources
}
let sourceIds = sources.compactMap { $0.id }
guard !sourceIds.isEmpty else {
cachedCompletionDates[goal.id] = nil
return nil
}
// Performance: Use cached evolution data if available
let cacheKey = goal.account?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
let evolutionData: [(date: Date, value: Decimal)]
if let cached = cachedEvolutionData[cacheKey] {
evolutionData = cached
} else {
let snapshots = snapshotRepository.fetchSnapshots(
for: sourceIds,
months: maxHistoryMonths
)
evolutionData = calculateEvolutionData(from: snapshots)
cachedEvolutionData[cacheKey] = evolutionData
}
guard evolutionData.count >= 3,
let first = evolutionData.suffix(6).first,
let last = evolutionData.suffix(6).last else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthsBetween = max(1, first.date.monthsBetween(last.date))
let delta = last.value - first.value
guard delta > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let monthlyGain = delta / Decimal(monthsBetween)
guard monthlyGain > 0 else {
cachedCompletionDates[goal.id] = nil
return nil
}
let currentValue = totalValue(for: goal)
let remaining = goal.targetDecimal - currentValue
guard remaining > 0 else {
let result = Date()
cachedCompletionDates[goal.id] = result
return result
}
let months = NSDecimalNumber(decimal: remaining / monthlyGain).doubleValue
let monthsRounded = Int(ceil(months))
let result = Calendar.current.date(byAdding: .month, value: monthsRounded, to: last.date)
cachedCompletionDates[goal.id] = result
return result
}
private func calculateEvolutionData(from snapshots: [Snapshot]) -> [(date: Date, value: Decimal)] {
let sortedSnapshots = snapshots.sorted { $0.date < $1.date }
let uniqueDates = Array(Set(sortedSnapshots.map { Calendar.current.startOfDay(for: $0.date) }))
.sorted()
guard !uniqueDates.isEmpty else { return [] }
var snapshotsBySource: [UUID: [(date: Date, value: Decimal)]] = [:]
for snapshot in sortedSnapshots {
guard let sourceId = snapshot.source?.id else { continue }
snapshotsBySource[sourceId, default: []].append(
(date: snapshot.date, value: snapshot.decimalValue)
)
}
var indices: [UUID: Int] = [:]
var evolution: [(date: Date, value: Decimal)] = []
for (index, date) in uniqueDates.enumerated() {
let nextDate = index + 1 < uniqueDates.count
? uniqueDates[index + 1]
: Date.distantFuture
var total: Decimal = 0
for (sourceId, sourceSnapshots) in snapshotsBySource {
var currentIndex = indices[sourceId] ?? 0
var latest: (date: Date, value: Decimal)?
while currentIndex < sourceSnapshots.count && sourceSnapshots[currentIndex].date < nextDate {
latest = sourceSnapshots[currentIndex]
currentIndex += 1
}
indices[sourceId] = currentIndex
if let latest {
total += latest.value
}
}
evolution.append((date: date, value: total))
}
return evolution
}
// MARK: - Private helpers
private func loadGoals() {
if showAllAccounts || selectedAccount == nil {
goalRepository.fetchGoals()
} else if let account = selectedAccount {
goalRepository.fetchGoals(for: account)
}
}
private func updateGoals(using repositoryGoals: [Goal]) {
if showAllAccounts || selectedAccount == nil {
goals = repositoryGoals
} else if let account = selectedAccount {
goals = repositoryGoals.filter { $0.account?.id == account.id }
} else {
goals = repositoryGoals
}
updateTotalValue()
}
private func updateTotalValue() {
if showAllAccounts || selectedAccount == nil {
totalValue = sourceRepository.sources.reduce(Decimal.zero) { $0 + $1.latestValue }
return
}
if let account = selectedAccount {
totalValue = sourceRepository.sources
.filter { $0.account?.id == account.id }
.reduce(Decimal.zero) { $0 + $1.latestValue }
}
}
}
struct GoalPaceStatus {
let expectedProgress: Double
let delta: Double
let isBehind: Bool
let statusText: String
}
@@ -0,0 +1,55 @@
import Foundation
import Combine
import CoreData
@MainActor
class JournalViewModel: ObservableObject {
@Published var snapshotNotes: [Snapshot] = []
@Published var monthlyNotes: [MonthlyNoteItem] = []
private let snapshotRepository: SnapshotRepository
private var cancellables = Set<AnyCancellable>()
init(snapshotRepository: SnapshotRepository? = nil) {
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
setupObservers()
refresh()
}
private func setupObservers() {
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
func refresh() {
snapshotNotes = snapshotRepository.fetchAllSnapshots()
.sorted { $0.date > $1.date }
let snapshotMonths = Set(snapshotNotes.map { $0.date.startOfMonth })
let noteMonths = Set(MonthlyCheckInStore.allNotes().map { $0.date.startOfMonth })
let allMonths = snapshotMonths.union(noteMonths)
monthlyNotes = allMonths
.sorted(by: >)
.map { date -> MonthlyNoteItem in
let entry = MonthlyCheckInStore.entry(for: date)
return MonthlyNoteItem(
date: date,
note: entry?.note ?? "",
rating: entry?.rating,
mood: entry?.mood
)
}
}
}
struct MonthlyNoteItem: Identifiable, Equatable {
var id: Date { date }
let date: Date
let note: String
let rating: Int?
let mood: MonthlyCheckInMood?
}
@@ -0,0 +1,128 @@
import Foundation
import Combine
import CoreData
@MainActor
class MonthlyCheckInViewModel: ObservableObject {
@Published var sourcesNeedingUpdate: [InvestmentSource] = []
@Published var sources: [InvestmentSource] = []
@Published var monthlySummary: MonthlySummary = .empty
@Published var recentNotes: [Snapshot] = []
@Published var isLoading = false
@Published var selectedAccount: Account?
@Published var showAllAccounts = true
@Published var selectedRange: DateRange = .thisMonth
@Published var checkInStats: MonthlyCheckInStats = .empty
private let sourceRepository: InvestmentSourceRepository
private let snapshotRepository: SnapshotRepository
private let calculationService: CalculationService
private var cancellables = Set<AnyCancellable>()
@MainActor
init(
sourceRepository: InvestmentSourceRepository? = nil,
snapshotRepository: SnapshotRepository? = nil,
calculationService: CalculationService? = nil
) {
let context = CoreDataStack.shared.viewContext
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository(context: context)
self.snapshotRepository = snapshotRepository ?? SnapshotRepository(context: context)
self.calculationService = calculationService ?? .shared
setupObservers()
refresh()
}
private func setupObservers() {
sourceRepository.$sources
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
func refresh() {
isLoading = true
let filtered = filteredSources()
let snapshots = filteredSnapshots(for: filtered)
let accountFilter = showAllAccounts ? nil : selectedAccount
sourcesNeedingUpdate = sourceRepository.fetchSourcesNeedingUpdate(for: accountFilter)
sources = filtered
monthlySummary = calculationService.calculateMonthlySummary(
sources: filtered,
snapshots: snapshots,
range: selectedRange
)
checkInStats = MonthlyCheckInStore.stats(referenceDate: selectedRange.end)
recentNotes = snapshots
.filter { ($0.notes?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
&& selectedRange.contains($0.date) }
.sorted { $0.date > $1.date }
.prefix(5)
.map { $0 }
isLoading = false
}
func duplicatePreviousMonthSnapshots(referenceDate: Date) {
let targetRange = DateRange.month(containing: referenceDate)
let previousRange = DateRange.month(containing: referenceDate.adding(months: -1))
let sources = filteredSources()
guard !sources.isEmpty else { return }
let targetSnapshots = snapshotRepository.fetchSnapshots(
from: targetRange.start,
to: targetRange.end
)
let targetSourceIds = Set(targetSnapshots.compactMap { $0.source?.id })
let now = Date()
let targetDate = targetRange.contains(now) ? now : targetRange.end
for source in sources {
let sourceId = source.id
if targetSourceIds.contains(sourceId) { continue }
let previousSnapshots = snapshotRepository.fetchSnapshots(for: source)
guard let previousSnapshot = previousSnapshots.first(where: { previousRange.contains($0.date) }) else {
continue
}
snapshotRepository.createSnapshot(
for: source,
date: targetDate,
value: previousSnapshot.decimalValue,
contribution: previousSnapshot.contribution?.decimalValue,
notes: previousSnapshot.notes
)
}
}
private func filteredSources() -> [InvestmentSource] {
if showAllAccounts || selectedAccount == nil {
return sourceRepository.sources
}
return sourceRepository.sources.filter { $0.account?.id == selectedAccount?.id }
}
private func filteredSnapshots(for sources: [InvestmentSource]) -> [Snapshot] {
let sourceIds = Set(sources.compactMap { $0.id })
return snapshotRepository.fetchAllSnapshots().filter { snapshot in
let sourceId = snapshot.source?.id
return sourceId.map(sourceIds.contains) ?? false
}
}
}
@@ -0,0 +1,286 @@
import Foundation
import Combine
import CoreData
import UIKit
@MainActor
class SettingsViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isPremium = false
@Published var isFamilyShared = false
@Published var notificationsEnabled = false
@Published var defaultNotificationTime = Date()
@Published var analyticsEnabled = true
@Published var currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
@Published var inputMode: InputMode = .simple
@Published var isLoading = false
@Published var showingPaywall = false
@Published var showingExportOptions = false
@Published var showingImportSheet = false
@Published var showingResetConfirmation = false
@Published var errorMessage: String?
@Published var successMessage: String?
// MARK: - Statistics
@Published var totalSources = 0
@Published var totalSnapshots = 0
@Published var totalCategories = 0
// MARK: - Dependencies
private let iapService: IAPService
private let notificationService: NotificationService
private let sourceRepository: InvestmentSourceRepository
private let categoryRepository: CategoryRepository
private let freemiumValidator: FreemiumValidator
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(
iapService: IAPService,
notificationService: NotificationService? = nil,
sourceRepository: InvestmentSourceRepository? = nil,
categoryRepository: CategoryRepository? = nil
) {
self.iapService = iapService
self.notificationService = notificationService ?? .shared
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
self.categoryRepository = categoryRepository ?? CategoryRepository()
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadSettings()
}
// MARK: - Setup
private func setupObservers() {
iapService.$isPremium
.receive(on: DispatchQueue.main)
.assign(to: &$isPremium)
iapService.$isFamilyShared
.receive(on: DispatchQueue.main)
.assign(to: &$isFamilyShared)
notificationService.$isAuthorized
.receive(on: DispatchQueue.main)
.assign(to: &$notificationsEnabled)
}
// MARK: - Data Loading
func loadSettings() {
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
defaultNotificationTime = settings.defaultNotificationTime ?? Date()
analyticsEnabled = settings.enableAnalytics
currencyCode = settings.currency
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
// Load statistics
totalSources = sourceRepository.sourceCount
totalCategories = categoryRepository.categories.count
totalSnapshots = sourceRepository.sources.reduce(0) { $0 + $1.snapshotCount }
FirebaseService.shared.logScreenView(screenName: "Settings")
}
// MARK: - Premium Actions
func upgradeToPremium() {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "settings_upgrade")
}
func restorePurchases() async {
isLoading = true
await iapService.restorePurchases()
isLoading = false
if isPremium {
successMessage = "Purchases restored successfully!"
FirebaseService.shared.logRestorePurchases(success: true)
} else {
errorMessage = "No purchases to restore"
FirebaseService.shared.logRestorePurchases(success: false)
}
}
// MARK: - Notification Settings
func requestNotificationPermission() async {
let granted = await notificationService.requestAuthorization()
if !granted {
errorMessage = "Please enable notifications in Settings"
}
}
func updateNotificationTime(_ time: Date) {
defaultNotificationTime = time
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.defaultNotificationTime = time
CoreDataStack.shared.save()
// Reschedule all notifications with new time
let sources = sourceRepository.fetchActiveSources()
notificationService.scheduleAllReminders(for: sources)
}
func openSystemSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
// MARK: - Export
func exportData(format: ExportService.ExportFormat) {
guard freemiumValidator.canExport() else {
showingPaywall = true
FirebaseService.shared.logPaywallShown(trigger: "export")
return
}
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let viewController = windowScene.windows.first?.rootViewController else {
return
}
ExportService.shared.share(
format: format,
sources: sourceRepository.sources,
categories: categoryRepository.categories,
from: viewController
)
}
var canExport: Bool {
freemiumValidator.canExport()
}
// MARK: - Analytics
func toggleAnalytics(_ enabled: Bool) {
analyticsEnabled = enabled
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.enableAnalytics = enabled
CoreDataStack.shared.save()
// Note: In production, you'd also update Firebase Analytics consent
}
// MARK: - Currency
func updateCurrency(_ code: String) {
currencyCode = code
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.currency = code
CoreDataStack.shared.save()
}
// MARK: - Input Mode
func updateInputMode(_ mode: InputMode) {
inputMode = mode
let context = CoreDataStack.shared.viewContext
let settings = AppSettings.getOrCreate(in: context)
settings.inputMode = mode.rawValue
CoreDataStack.shared.save()
}
// MARK: - Data Management
func resetAllData() {
let context = CoreDataStack.shared.viewContext
// Delete all snapshots, sources, and categories
for source in sourceRepository.sources {
context.delete(source)
}
for category in categoryRepository.categories {
context.delete(category)
}
let assetRequest: NSFetchRequest<Asset> = Asset.fetchRequest()
let accountRequest: NSFetchRequest<Account> = Account.fetchRequest()
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
if let assets = try? context.fetch(assetRequest) {
assets.forEach { context.delete($0) }
}
if let accounts = try? context.fetch(accountRequest) {
accounts.forEach { context.delete($0) }
}
if let goals = try? context.fetch(goalRequest) {
goals.forEach { context.delete($0) }
}
CoreDataStack.shared.save()
// Clear notifications
notificationService.cancelAllReminders()
// Recreate default categories
categoryRepository.createDefaultCategoriesIfNeeded()
_ = AccountRepository().createDefaultAccountIfNeeded()
// Reload data
loadSettings()
successMessage = "All data has been reset"
}
// MARK: - Computed Properties
var appVersion: String {
"\(AppConstants.appVersion) (\(AppConstants.buildNumber))"
}
var premiumStatusText: String {
if isPremium {
return isFamilyShared ? "Premium (Family)" : "Premium"
}
return "Free"
}
var sourceLimitText: String {
if isPremium {
return "Unlimited"
}
return "\(totalSources)/\(FreemiumLimits.maxSources)"
}
var historyLimitText: String {
if isPremium {
return "Full history"
}
return "Last \(FreemiumLimits.maxHistoricalMonths) months"
}
var storageUsedText: String {
let bytes = calculateStorageUsed()
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private func calculateStorageUsed() -> Int {
guard let storeURL = CoreDataStack.sharedStoreURL else { return 0 }
do {
let attributes = try FileManager.default.attributesOfItem(atPath: storeURL.path)
return attributes[.size] as? Int ?? 0
} catch {
return 0
}
}
}
@@ -0,0 +1,222 @@
import Foundation
import Combine
@MainActor
class SnapshotFormViewModel: ObservableObject {
// MARK: - Published Properties
@Published var date = Date()
@Published var valueString = ""
@Published var contributionString = ""
@Published var notes = ""
@Published var includeContribution = false
@Published var inputMode: InputMode = .simple
@Published var currencySymbol = ""
@Published var isValid = false
@Published var errorMessage: String?
// MARK: - Mode
enum Mode {
case add
case edit(Snapshot)
}
let mode: Mode
let source: InvestmentSource
// MARK: - Dependencies
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(source: InvestmentSource, mode: Mode = .add) {
self.source = source
self.mode = mode
let settings = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext)
if let accountCurrency = source.account?.currency, !accountCurrency.isEmpty {
currencySymbol = CurrencyFormatter.symbol(for: accountCurrency)
} else {
currencySymbol = settings.currencySymbol
}
if let accountMode = InputMode(rawValue: source.account?.inputMode ?? "") {
inputMode = accountMode
} else {
inputMode = InputMode(rawValue: settings.inputMode) ?? .simple
}
includeContribution = inputMode == .detailed
setupValidation()
// Pre-fill for edit mode
if case .edit(let snapshot) = mode {
date = snapshot.date
valueString = formatDecimalForInput(snapshot.decimalValue)
if let contribution = snapshot.contribution {
includeContribution = true
contributionString = formatDecimalForInput(contribution.decimalValue)
}
notes = snapshot.notes ?? ""
}
}
// MARK: - Validation
private func setupValidation() {
Publishers.CombineLatest3($valueString, $contributionString, $includeContribution)
.map { [weak self] valueStr, contribStr, includeContrib in
self?.validateInputs(
valueString: valueStr,
contributionString: contribStr,
includeContribution: includeContrib
) ?? false
}
.assign(to: &$isValid)
}
private func validateInputs(
valueString: String,
contributionString: String,
includeContribution: Bool
) -> Bool {
// Value is required
guard let value = parseDecimal(valueString), value >= 0 else {
return false
}
// Contribution is optional but must be valid if included
if includeContribution {
guard let contribution = parseDecimal(contributionString), contribution >= 0 else {
return false
}
}
return true
}
// MARK: - Parsing
private func parseDecimal(_ string: String) -> Decimal? {
let cleaned = string
.replacingOccurrences(of: currencySymbol, with: "")
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: "en_US")
return formatter.number(from: cleaned)?.decimalValue
}
private func formatDecimalForInput(_ decimal: Decimal) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 2
formatter.groupingSeparator = ""
return formatter.string(from: decimal as NSDecimalNumber) ?? ""
}
// MARK: - Computed Properties
var value: Decimal? {
parseDecimal(valueString)
}
var contribution: Decimal? {
guard includeContribution else { return nil }
return parseDecimal(contributionString)
}
var formattedValue: String {
guard let value = value else { return CurrencyFormatter.format(Decimal.zero) }
return value.currencyString
}
var title: String {
switch mode {
case .add:
return "Add Snapshot"
case .edit:
return "Edit Snapshot"
}
}
var buttonTitle: String {
switch mode {
case .add:
return "Add Snapshot"
case .edit:
return "Save Changes"
}
}
// MARK: - Previous Value Reference
var previousValue: Decimal? {
source.latestSnapshot?.decimalValue
}
var previousValueString: String {
guard let previous = previousValue else { return "No previous value" }
return "Previous: \(previous.currencyString)"
}
var changeFromPrevious: Decimal? {
guard let current = value, let previous = previousValue else { return nil }
return current - previous
}
var changePercentageFromPrevious: Double? {
guard let current = value,
let previous = previousValue,
previous != 0 else { return nil }
return NSDecimalNumber(decimal: (current - previous) / previous).doubleValue * 100
}
var formattedChange: String? {
guard let change = changeFromPrevious else { return nil }
let prefix = change >= 0 ? "+" : ""
return "\(prefix)\(change.currencyString)"
}
var formattedChangePercentage: String? {
guard let percentage = changePercentageFromPrevious else { return nil }
let prefix = percentage >= 0 ? "+" : ""
return String(format: "\(prefix)%.2f%%", percentage)
}
// MARK: - Duplicate Previous
func prefillFromPreviousSnapshot() {
guard case .add = mode,
let previous = source.latestSnapshot else { return }
valueString = formatDecimalForInput(previous.decimalValue)
if let contribution = previous.contribution {
includeContribution = true
contributionString = formatDecimalForInput(contribution.decimalValue)
}
notes = previous.notes ?? ""
date = Date()
}
// MARK: - Date Validation
var isDateInFuture: Bool {
date > Date()
}
var dateWarning: String? {
if isDateInFuture {
return "Date is in the future"
}
return nil
}
}
@@ -0,0 +1,324 @@
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 var cancellables = Set<AnyCancellable>()
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)
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)
}
// 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<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
}
}
@@ -0,0 +1,219 @@
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<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),
$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
}
}