initial version
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class AccountRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var accounts: [Account] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchAccounts()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchAccounts() {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
let fetched = try self.context.fetch(request)
|
||||
DispatchQueue.main.async {
|
||||
self.accounts = fetched
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch accounts: \(error)")
|
||||
DispatchQueue.main.async {
|
||||
self.accounts = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAccount(by id: UUID) -> Account? {
|
||||
let request: NSFetchRequest<Account> = Account.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createAccount(
|
||||
name: String,
|
||||
currency: String?,
|
||||
inputMode: InputMode,
|
||||
notificationFrequency: NotificationFrequency,
|
||||
customFrequencyMonths: Int = 1
|
||||
) -> Account {
|
||||
let account = Account(context: context)
|
||||
account.name = name
|
||||
account.currency = currency
|
||||
account.inputMode = inputMode.rawValue
|
||||
account.notificationFrequency = notificationFrequency.rawValue
|
||||
account.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||
account.sortOrder = Int16(accounts.count)
|
||||
save()
|
||||
return account
|
||||
}
|
||||
|
||||
func createDefaultAccountIfNeeded() -> Account {
|
||||
if let existing = accounts.first {
|
||||
return existing
|
||||
}
|
||||
|
||||
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
|
||||
let account = createAccount(
|
||||
name: "Personal",
|
||||
currency: defaultCurrency,
|
||||
inputMode: .simple,
|
||||
notificationFrequency: .monthly
|
||||
)
|
||||
|
||||
// Attach existing sources to the default account
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
if let sources = try? context.fetch(request) {
|
||||
for source in sources where source.account == nil {
|
||||
source.account = account
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateAccount(
|
||||
_ account: Account,
|
||||
name: String? = nil,
|
||||
currency: String? = nil,
|
||||
inputMode: InputMode? = nil,
|
||||
notificationFrequency: NotificationFrequency? = nil,
|
||||
customFrequencyMonths: Int? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
account.name = name
|
||||
}
|
||||
if let currency = currency {
|
||||
account.currency = currency
|
||||
}
|
||||
if let inputMode = inputMode {
|
||||
account.inputMode = inputMode.rawValue
|
||||
}
|
||||
if let notificationFrequency = notificationFrequency {
|
||||
account.notificationFrequency = notificationFrequency.rawValue
|
||||
}
|
||||
if let customMonths = customFrequencyMonths {
|
||||
account.customFrequencyMonths = Int16(customMonths)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteAccount(_ account: Account) {
|
||||
context.delete(account)
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchAccounts()
|
||||
} catch {
|
||||
print("Failed to save accounts: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class CategoryRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var categories: [Category] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchCategories()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
guard isRelevantChange(notification) else { return }
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchCategories() {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
|
||||
NSSortDescriptor(keyPath: \Category.name, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
self.categories = try self.context.fetch(request)
|
||||
} catch {
|
||||
print("Failed to fetch categories: \(error)")
|
||||
self.categories = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCategory(by id: UUID) -> Category? {
|
||||
let request: NSFetchRequest<Category> = Category.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createCategory(
|
||||
name: String,
|
||||
colorHex: String,
|
||||
icon: String
|
||||
) -> Category {
|
||||
let category = Category(context: context)
|
||||
category.name = name
|
||||
category.colorHex = colorHex
|
||||
category.icon = icon
|
||||
category.sortOrder = Int16(categories.count)
|
||||
|
||||
save()
|
||||
return category
|
||||
}
|
||||
|
||||
func createDefaultCategoriesIfNeeded() {
|
||||
guard categories.isEmpty else { return }
|
||||
Category.createDefaultCategories(in: context)
|
||||
fetchCategories()
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateCategory(
|
||||
_ category: Category,
|
||||
name: String? = nil,
|
||||
colorHex: String? = nil,
|
||||
icon: String? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
category.name = name
|
||||
}
|
||||
if let colorHex = colorHex {
|
||||
category.colorHex = colorHex
|
||||
}
|
||||
if let icon = icon {
|
||||
category.icon = icon
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func updateSortOrder(_ categories: [Category]) {
|
||||
for (index, category) in categories.enumerated() {
|
||||
category.sortOrder = Int16(index)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteCategory(_ category: Category) {
|
||||
context.delete(category)
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteCategory(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
guard index < categories.count else { continue }
|
||||
context.delete(categories[index])
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
var totalValue: Decimal {
|
||||
categories.reduce(Decimal.zero) { $0 + $1.totalValue }
|
||||
}
|
||||
|
||||
func categoryAllocation() -> [(category: Category, percentage: Double)] {
|
||||
let total = totalValue
|
||||
guard total > 0 else { return [] }
|
||||
|
||||
return categories.map { category in
|
||||
let percentage = NSDecimalNumber(decimal: category.totalValue / total).doubleValue * 100
|
||||
return (category: category, percentage: percentage)
|
||||
}.sorted { $0.percentage > $1.percentage }
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchCategories()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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 Category }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class GoalRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var goals: [Goal] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchGoals()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
fetchGoals()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchGoals(for account: Account? = nil) {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
||||
if let account = account {
|
||||
request.predicate = NSPredicate(format: "account == %@", account)
|
||||
}
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
self.goals = try self.context.fetch(request)
|
||||
} catch {
|
||||
print("Failed to fetch goals: \(error)")
|
||||
self.goals = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createGoal(
|
||||
name: String,
|
||||
targetAmount: Decimal,
|
||||
targetDate: Date? = nil,
|
||||
account: Account?
|
||||
) -> Goal {
|
||||
let goal = Goal(context: context)
|
||||
goal.name = name
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
goal.targetDate = targetDate
|
||||
goal.account = account
|
||||
save()
|
||||
return goal
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateGoal(
|
||||
_ goal: Goal,
|
||||
name: String? = nil,
|
||||
targetAmount: Decimal? = nil,
|
||||
targetDate: Date? = nil,
|
||||
clearTargetDate: Bool = false,
|
||||
isActive: Bool? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
goal.name = name
|
||||
}
|
||||
if let targetAmount = targetAmount {
|
||||
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
||||
}
|
||||
if clearTargetDate {
|
||||
goal.targetDate = nil
|
||||
} else if let targetDate = targetDate {
|
||||
goal.targetDate = targetDate
|
||||
}
|
||||
if let isActive = isActive {
|
||||
goal.isActive = isActive
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteGoal(_ goal: Goal) {
|
||||
context.delete(goal)
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchGoals()
|
||||
} catch {
|
||||
print("Failed to save goals: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class InvestmentSourceRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
@Published private(set) var sources: [InvestmentSource] = []
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
fetchSources()
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
guard isRelevantChange(notification) else { return }
|
||||
fetchSources()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchSources(account: Account? = nil) {
|
||||
context.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
if let account = account {
|
||||
request.predicate = NSPredicate(format: "account == %@", account)
|
||||
}
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
||||
]
|
||||
|
||||
do {
|
||||
self.sources = try self.context.fetch(request)
|
||||
} catch {
|
||||
print("Failed to fetch sources: \(error)")
|
||||
self.sources = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSource(by id: UUID) -> InvestmentSource? {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
func fetchSources(for category: Category) -> [InvestmentSource] {
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "category == %@", category)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
|
||||
]
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchActiveSources() -> [InvestmentSource] {
|
||||
sources.filter { $0.isActive }
|
||||
}
|
||||
|
||||
func fetchSourcesNeedingUpdate(for account: Account? = nil) -> [InvestmentSource] {
|
||||
let filtered = account == nil ? sources : sources.filter { $0.account?.id == account?.id }
|
||||
return filtered.filter { $0.needsUpdate }
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createSource(
|
||||
name: String,
|
||||
category: Category,
|
||||
notificationFrequency: NotificationFrequency = .monthly,
|
||||
customFrequencyMonths: Int = 1,
|
||||
account: Account? = nil
|
||||
) -> InvestmentSource {
|
||||
let source = InvestmentSource(context: context)
|
||||
source.name = name
|
||||
source.category = category
|
||||
source.notificationFrequency = notificationFrequency.rawValue
|
||||
source.customFrequencyMonths = Int16(customFrequencyMonths)
|
||||
source.account = account
|
||||
|
||||
save()
|
||||
return source
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateSource(
|
||||
_ source: InvestmentSource,
|
||||
name: String? = nil,
|
||||
category: Category? = nil,
|
||||
notificationFrequency: NotificationFrequency? = nil,
|
||||
customFrequencyMonths: Int? = nil,
|
||||
isActive: Bool? = nil,
|
||||
account: Account? = nil
|
||||
) {
|
||||
if let name = name {
|
||||
source.name = name
|
||||
}
|
||||
if let category = category {
|
||||
source.category = category
|
||||
}
|
||||
if let frequency = notificationFrequency {
|
||||
source.notificationFrequency = frequency.rawValue
|
||||
}
|
||||
if let customMonths = customFrequencyMonths {
|
||||
source.customFrequencyMonths = Int16(customMonths)
|
||||
}
|
||||
if let isActive = isActive {
|
||||
source.isActive = isActive
|
||||
}
|
||||
if let account = account {
|
||||
source.account = account
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleActive(_ source: InvestmentSource) {
|
||||
source.isActive.toggle()
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteSource(_ source: InvestmentSource) {
|
||||
context.delete(source)
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSource(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
guard index < sources.count else { continue }
|
||||
context.delete(sources[index])
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Statistics
|
||||
|
||||
var sourceCount: Int {
|
||||
sources.count
|
||||
}
|
||||
|
||||
var activeSourceCount: Int {
|
||||
sources.filter { $0.isActive }.count
|
||||
}
|
||||
|
||||
var totalValue: Decimal {
|
||||
sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
}
|
||||
|
||||
func topSources(limit: Int = 5) -> [InvestmentSource] {
|
||||
sources
|
||||
.sorted { $0.latestValue > $1.latestValue }
|
||||
.prefix(limit)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
// MARK: - Freemium Check
|
||||
|
||||
var canAddMoreSources: Bool {
|
||||
// This will be checked against FreemiumValidator
|
||||
true
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
fetchSources()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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 InvestmentSource }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Combine
|
||||
|
||||
class SnapshotRepository: ObservableObject {
|
||||
private let context: NSManagedObjectContext
|
||||
private let cache = NSCache<NSString, NSArray>()
|
||||
@Published private(set) var cacheVersion: Int = 0
|
||||
|
||||
@Published private(set) var snapshots: [Snapshot] = []
|
||||
|
||||
// MARK: - Performance: Shared DateFormatter
|
||||
private static let monthYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Performance: Cached Calendar
|
||||
private static let calendar = Calendar.current
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
// Performance: Increase cache limits for better hit rate
|
||||
cache.countLimit = 12
|
||||
cache.totalCostLimit = 4_000_000 // 4 MB
|
||||
setupNotificationObserver()
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
func fetchSnapshots(for source: InvestmentSource) -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source == %@", source)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchSnapshot(by id: UUID) -> Snapshot? {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? context.fetch(request).first
|
||||
}
|
||||
|
||||
func fetchAllSnapshots() -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchSnapshots(for account: Account) -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source.account == %@", account)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchSnapshots(from startDate: Date, to endDate: Date) -> [Snapshot] {
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
request.predicate = NSPredicate(
|
||||
format: "date >= %@ AND date <= %@",
|
||||
startDate as NSDate,
|
||||
endDate as NSDate
|
||||
)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: true)
|
||||
]
|
||||
request.fetchBatchSize = 200
|
||||
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
func fetchLatestSnapshots() -> [Snapshot] {
|
||||
// Get the latest snapshot for each source
|
||||
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
||||
let sources = (try? context.fetch(request)) ?? []
|
||||
|
||||
return sources.compactMap { $0.latestSnapshot }
|
||||
}
|
||||
|
||||
// MARK: - Create
|
||||
|
||||
@discardableResult
|
||||
func createSnapshot(
|
||||
for source: InvestmentSource,
|
||||
date: Date = Date(),
|
||||
value: Decimal,
|
||||
contribution: Decimal? = nil,
|
||||
notes: String? = nil
|
||||
) -> Snapshot {
|
||||
let snapshot = Snapshot(context: context)
|
||||
snapshot.source = source
|
||||
snapshot.date = date
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
if let contribution = contribution {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
snapshot.notes = notes
|
||||
|
||||
save()
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
|
||||
func updateSnapshot(
|
||||
_ snapshot: Snapshot,
|
||||
date: Date? = nil,
|
||||
value: Decimal? = nil,
|
||||
contribution: Decimal? = nil,
|
||||
notes: String? = nil,
|
||||
clearContribution: Bool = false,
|
||||
clearNotes: Bool = false
|
||||
) {
|
||||
if let date = date {
|
||||
snapshot.date = date
|
||||
}
|
||||
if let value = value {
|
||||
snapshot.value = NSDecimalNumber(decimal: value)
|
||||
}
|
||||
if clearContribution {
|
||||
snapshot.contribution = nil
|
||||
} else if let contribution = contribution {
|
||||
snapshot.contribution = NSDecimalNumber(decimal: contribution)
|
||||
}
|
||||
if clearNotes {
|
||||
snapshot.notes = nil
|
||||
} else if let notes = notes {
|
||||
snapshot.notes = notes
|
||||
}
|
||||
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteSnapshot(_ snapshot: Snapshot) {
|
||||
context.delete(snapshot)
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSnapshots(_ snapshots: [Snapshot]) {
|
||||
snapshots.forEach { context.delete($0) }
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSnapshot(at offsets: IndexSet, from snapshots: [Snapshot]) {
|
||||
for index in offsets {
|
||||
guard index < snapshots.count else { continue }
|
||||
context.delete(snapshots[index])
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Filtered Fetch (Freemium)
|
||||
|
||||
func fetchSnapshots(
|
||||
for source: InvestmentSource,
|
||||
limitedToMonths months: Int?
|
||||
) -> [Snapshot] {
|
||||
var snapshots = fetchSnapshots(for: source)
|
||||
|
||||
if let months = months {
|
||||
// Performance: Use cached calendar
|
||||
let cutoffDate = Self.calendar.date(
|
||||
byAdding: .month,
|
||||
value: -months,
|
||||
to: Date()
|
||||
) ?? Date()
|
||||
|
||||
snapshots = snapshots.filter { $0.date >= cutoffDate }
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func fetchSnapshots(
|
||||
for sourceIds: [UUID],
|
||||
months: Int? = nil
|
||||
) -> [Snapshot] {
|
||||
guard !sourceIds.isEmpty else { return [] }
|
||||
|
||||
// Performance: Use cached calendar
|
||||
let cutoffDate: Date? = {
|
||||
guard let months = months else { return nil }
|
||||
return Self.calendar.date(byAdding: .month, value: -months, to: Date())
|
||||
}()
|
||||
|
||||
let key = cacheKey(
|
||||
sourceIds: sourceIds,
|
||||
months: months
|
||||
)
|
||||
|
||||
if let cached = cache.object(forKey: key) as? [Snapshot] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
|
||||
var predicates: [NSPredicate] = [
|
||||
NSPredicate(format: "source.id IN %@", sourceIds)
|
||||
]
|
||||
|
||||
if let cutoffDate {
|
||||
predicates.append(NSPredicate(format: "date >= %@", cutoffDate as NSDate))
|
||||
}
|
||||
|
||||
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Snapshot.date, ascending: false)
|
||||
]
|
||||
request.fetchBatchSize = 300
|
||||
|
||||
let fetched = (try? context.fetch(request)) ?? []
|
||||
cache.setObject(fetched as NSArray, forKey: key, cost: fetched.count)
|
||||
return fetched
|
||||
}
|
||||
|
||||
// MARK: - Historical Data
|
||||
|
||||
func getMonthlyValues(for source: InvestmentSource) -> [(date: Date, value: Decimal)] {
|
||||
let snapshots = fetchSnapshots(for: source).reversed()
|
||||
|
||||
var monthlyData: [(date: Date, value: Decimal)] = []
|
||||
monthlyData.reserveCapacity(min(snapshots.count, 60))
|
||||
var processedMonths: Set<String> = []
|
||||
processedMonths.reserveCapacity(60)
|
||||
|
||||
// Performance: Use shared formatter
|
||||
let formatter = Self.monthYearFormatter
|
||||
|
||||
for snapshot in snapshots {
|
||||
let monthKey = formatter.string(from: snapshot.date)
|
||||
if !processedMonths.contains(monthKey) {
|
||||
processedMonths.insert(monthKey)
|
||||
monthlyData.append((date: snapshot.date, value: snapshot.decimalValue))
|
||||
}
|
||||
}
|
||||
|
||||
return monthlyData
|
||||
}
|
||||
|
||||
func getPortfolioHistory() -> [(date: Date, totalValue: Decimal)] {
|
||||
let allSnapshots = fetchAllSnapshots()
|
||||
|
||||
// Performance: Pre-allocate dictionary
|
||||
var dateValues: [Date: Decimal] = [:]
|
||||
dateValues.reserveCapacity(min(allSnapshots.count, 365))
|
||||
|
||||
// Performance: Use cached calendar
|
||||
let calendar = Self.calendar
|
||||
|
||||
for snapshot in allSnapshots {
|
||||
let startOfDay = calendar.startOfDay(for: snapshot.date)
|
||||
dateValues[startOfDay, default: Decimal.zero] += snapshot.decimalValue
|
||||
}
|
||||
|
||||
return dateValues
|
||||
.map { (date: $0.key, totalValue: $0.value) }
|
||||
.sorted { $0.date < $1.date }
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
invalidateCache()
|
||||
CoreDataStack.shared.refreshWidgetData()
|
||||
} catch {
|
||||
print("Failed to save context: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
private func cacheKey(
|
||||
sourceIds: [UUID],
|
||||
months: Int?
|
||||
) -> NSString {
|
||||
let sortedIds = sourceIds.sorted().map { $0.uuidString }.joined(separator: ",")
|
||||
let monthsPart = months.map(String.init) ?? "all"
|
||||
return NSString(string: "\(cacheVersion)|\(monthsPart)|\(sortedIds)")
|
||||
}
|
||||
|
||||
private func invalidateCache() {
|
||||
cache.removeAllObjects()
|
||||
cacheVersion &+= 1
|
||||
}
|
||||
|
||||
private func setupNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(contextDidChange),
|
||||
name: .NSManagedObjectContextObjectsDidChange,
|
||||
object: context
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func contextDidChange(_ notification: Notification) {
|
||||
invalidateCache()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
class TransactionRepository {
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func fetchTransactions(for source: InvestmentSource) -> [Transaction] {
|
||||
let request: NSFetchRequest<Transaction> = Transaction.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "source == %@", source)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \Transaction.date, ascending: false)
|
||||
]
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createTransaction(
|
||||
source: InvestmentSource,
|
||||
type: TransactionType,
|
||||
date: Date,
|
||||
shares: Decimal?,
|
||||
price: Decimal?,
|
||||
amount: Decimal?,
|
||||
notes: String?
|
||||
) -> Transaction {
|
||||
let transaction = Transaction(context: context)
|
||||
transaction.source = source
|
||||
transaction.type = type.rawValue
|
||||
transaction.date = date
|
||||
if let shares = shares {
|
||||
transaction.shares = NSDecimalNumber(decimal: shares)
|
||||
}
|
||||
if let price = price {
|
||||
transaction.price = NSDecimalNumber(decimal: price)
|
||||
}
|
||||
if let amount = amount {
|
||||
transaction.amount = NSDecimalNumber(decimal: amount)
|
||||
}
|
||||
transaction.notes = notes
|
||||
|
||||
save()
|
||||
return transaction
|
||||
}
|
||||
|
||||
func deleteTransaction(_ transaction: Transaction) {
|
||||
context.delete(transaction)
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard context.hasChanges else { return }
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save transaction: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user