InvestmentTrackerApp/InvestmentTracker/ViewModels/SourceListViewModel.swift

214 lines
6.0 KiB
Swift

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 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 = InvestmentSourceRepository(),
categoryRepository: CategoryRepository = CategoryRepository(),
iapService: IAPService
) {
self.sourceRepository = sourceRepository
self.categoryRepository = categoryRepository
self.freemiumValidator = FreemiumValidator(iapService: iapService)
setupObservers()
loadData()
}
// MARK: - Setup
private func setupObservers() {
sourceRepository.$sources
.receive(on: DispatchQueue.main)
.sink { [weak self] sources in
self?.filterAndSortSources(sources)
}
.store(in: &cancellables)
categoryRepository.$categories
.receive(on: DispatchQueue.main)
.sink { [weak self] categories in
self?.categories = categories
}
.store(in: &cancellables)
$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
self?.filterAndSortSources(self?.sourceRepository.sources ?? [])
}
.store(in: &cancellables)
$selectedCategory
.sink { [weak self] _ in
self?.filterAndSortSources(self?.sourceRepository.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
// 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
) {
let source = sourceRepository.createSource(
name: name,
category: category,
notificationFrequency: frequency,
customFrequencyMonths: customMonths
)
// 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
}
}