initial version
This commit is contained in:
@@ -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