initial version
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user