7cb5f92cf4
Deleted files: - AddTransactionView.swift - TransactionRepository.swift - Transaction+CoreDataClass.swift Cleaned files: - SourceDetailViewModel: removed transactions published property, showingAddTransaction flag, transactionRepository dependency, addTransaction() and deleteTransaction() methods - SourceDetailView: removed transactionsSection, TransactionRow struct, Add Transaction button from quickActions, sheet presentation - InvestmentSource+CoreDataClass: removed transactions NSManaged property, transactionsArray, totalInvested, totalDividends, totalFees computed properties, and all transaction Core Data accessors - SettingsViewModel: removed "Transaction" from resetAllData entity list - SampleDataService: removed transactionRepository and sample transaction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
347 lines
11 KiB
Swift
347 lines
11 KiB
Swift
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 isDeleted = false
|
|
|
|
@Published var isLoading = false
|
|
@Published var showingAddSnapshot = false
|
|
@Published var showingEditSource = false
|
|
@Published var showingPaywall = 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 calculationService: CalculationService
|
|
private let predictionEngine: PredictionEngine
|
|
private let freemiumValidator: FreemiumValidator
|
|
private let iapService: IAPService
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var isRefreshing = false
|
|
private var refreshQueued = false
|
|
private var refreshTask: Task<Void, Never>?
|
|
private let sourceName: String
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
source: InvestmentSource,
|
|
snapshotRepository: SnapshotRepository? = nil,
|
|
sourceRepository: InvestmentSourceRepository? = nil,
|
|
calculationService: CalculationService? = nil,
|
|
predictionEngine: PredictionEngine? = nil,
|
|
iapService: IAPService
|
|
) {
|
|
self.source = source
|
|
self.sourceName = source.name
|
|
self.snapshotRepository = snapshotRepository ?? SnapshotRepository()
|
|
self.sourceRepository = sourceRepository ?? InvestmentSourceRepository()
|
|
self.calculationService = calculationService ?? .shared
|
|
self.predictionEngine = predictionEngine ?? .shared
|
|
self.freemiumValidator = FreemiumValidator(iapService: iapService)
|
|
self.iapService = iapService
|
|
|
|
loadData()
|
|
setupObservers()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupObservers() {
|
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange)
|
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
|
.sink { [weak self] notification in
|
|
guard let self,
|
|
self.isRelevantChange(notification) else { return }
|
|
self.refreshData()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
iapService.$isPremium
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.refreshData()
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: - Data Loading
|
|
|
|
func loadData() {
|
|
isLoading = true
|
|
refreshData()
|
|
isLoading = false
|
|
|
|
FirebaseService.shared.logScreenView(screenName: "SourceDetail")
|
|
}
|
|
|
|
func refreshData() {
|
|
refreshQueued = true
|
|
guard !isRefreshing else { return }
|
|
isRefreshing = true
|
|
|
|
// Cancel any previous refresh task to avoid stale updates
|
|
refreshTask?.cancel()
|
|
|
|
refreshTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
guard !self.isDeleted, !self.source.isDeleted, self.source.managedObjectContext != nil else {
|
|
self.isDeleted = true
|
|
self.isRefreshing = false
|
|
return
|
|
}
|
|
while self.refreshQueued && !Task.isCancelled {
|
|
self.refreshQueued = false
|
|
|
|
// Fetch snapshots (filtered by freemium limits)
|
|
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
|
let filteredSnapshots = freemiumValidator.filterSnapshots(allSnapshots)
|
|
|
|
// Check for cancellation before updating UI
|
|
guard !Task.isCancelled else { break }
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
}
|
|
self.isRefreshing = false
|
|
}
|
|
}
|
|
|
|
/// Cancel any ongoing background tasks - call when view disappears
|
|
func cancelPendingTasks() {
|
|
refreshTask?.cancel()
|
|
refreshTask = nil
|
|
}
|
|
|
|
// 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: - 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
|
|
}
|
|
|
|
func deleteSource() {
|
|
guard !isDeleted else { return }
|
|
// Mark deleted first so the view can dismiss before any further access.
|
|
isDeleted = true
|
|
snapshots = []
|
|
chartData = []
|
|
predictionResult = nil
|
|
// Cancel any pending notifications for this source
|
|
NotificationService.shared.cancelReminder(for: source)
|
|
|
|
// Log analytics before deletion
|
|
FirebaseService.shared.logSourceDeleted(categoryName: source.category?.name ?? "Uncategorized")
|
|
|
|
// Delete the source using the existing repository
|
|
sourceRepository.deleteSource(source)
|
|
}
|
|
|
|
// 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 safeSourceName: String {
|
|
if source.isDeleted || source.managedObjectContext == nil {
|
|
return sourceName
|
|
}
|
|
return source.name
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var hiddenSnapshotCount: Int {
|
|
// For free users, count how many snapshots are hidden due to the 12-month limit
|
|
guard !freemiumValidator.isPremium else { return 0 }
|
|
|
|
// Get all snapshots without the date filter
|
|
let allSnapshots = snapshotRepository.fetchSnapshots(for: source)
|
|
return max(0, allSnapshots.count - snapshots.count)
|
|
}
|
|
|
|
var visibleSnapshots: [Snapshot] {
|
|
// Premium users see all snapshots (already filtered by freemiumValidator in refreshData)
|
|
// Free users also see all their filtered snapshots (limited to last 12 months)
|
|
snapshots
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|