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