d88cc59627
Un Goal ya podía acotarse por cuenta; ahora también por categoría de fuente, de modo que se puede fijar "100.000 € en Cash" y que el progreso cuente solo las fuentes de esa categoría. - CoreData: relación Goal.category ↔ Category.goals (opcional, Nullify) - Goal.includes(_:)/relevantSources(from:)/scopeKey centralizan el filtro de ámbito (cuenta Y categoría) que antes estaba duplicado en GoalsViewModel, AddSourceView y NotificationService - GoalEditorView: sección "Ámbito" con selector de categoría - Chip de categoría en la lista de Goals y en la tarjeta del Dashboard - ETA del Dashboard para goals con ámbito: usa la proyección del propio goal en vez de la evolución global de la cartera (que daría "objetivo alcanzado" en cuanto la cartera total superara el importe) - GoalRepository e ImportService deduplican por (nombre, cuenta, categoría) - Export/Import de goals incluye "category" - Nuevas claves localizadas en los 7 idiomas Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
149 lines
4.6 KiB
Swift
149 lines
4.6 KiB
Swift
import Foundation
|
|
import CoreData
|
|
import Combine
|
|
|
|
@MainActor
|
|
class GoalRepository: ObservableObject {
|
|
private let context: NSManagedObjectContext
|
|
|
|
@Published private(set) var goals: [Goal] = []
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
|
|
self.context = context
|
|
fetchGoals()
|
|
setupNotificationObserver()
|
|
}
|
|
|
|
private func setupNotificationObserver() {
|
|
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.fetchGoals()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
NotificationCenter.default.publisher(for: .cloudKitForceReload)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in self?.fetchGoals() }
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
// MARK: - Fetch
|
|
|
|
func fetchGoals(for account: Account? = nil) {
|
|
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
|
|
if let account = account {
|
|
request.predicate = NSPredicate(format: "account == %@", account)
|
|
}
|
|
request.sortDescriptors = [
|
|
NSSortDescriptor(keyPath: \Goal.createdAt, ascending: true)
|
|
]
|
|
// Performance: Add batch size for fetches
|
|
request.fetchBatchSize = 50
|
|
|
|
do {
|
|
goals = try context.fetch(request)
|
|
} catch {
|
|
print("Failed to fetch goals: \(error)")
|
|
goals = []
|
|
}
|
|
}
|
|
|
|
// MARK: - Create
|
|
|
|
/// Check if a goal with the given name already exists in the same scope
|
|
/// (account + category), case-insensitive
|
|
func goalExists(name: String, in account: Account?, category: Category? = nil) -> Bool {
|
|
findGoal(byName: name, in: account, category: category) != nil
|
|
}
|
|
|
|
/// Find an existing goal by name (case-insensitive) within the same scope
|
|
func findGoal(byName name: String, in account: Account?, category: Category? = nil) -> Goal? {
|
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let normalized = trimmed.lowercased()
|
|
return goals.first { goal in
|
|
let nameMatches = goal.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
|
|
let accountMatches = goal.account?.id == account?.id
|
|
let categoryMatches = goal.category?.id == category?.id
|
|
return nameMatches && accountMatches && categoryMatches
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func createGoal(
|
|
name: String,
|
|
targetAmount: Decimal,
|
|
targetDate: Date? = nil,
|
|
account: Account?,
|
|
category: Category? = nil
|
|
) -> Goal {
|
|
// Check if goal with same name already exists in this scope
|
|
if let existing = findGoal(byName: name, in: account, category: category) {
|
|
return existing
|
|
}
|
|
|
|
let goal = Goal(context: context)
|
|
goal.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
|
|
goal.targetDate = targetDate
|
|
goal.account = account
|
|
goal.category = category
|
|
save()
|
|
return goal
|
|
}
|
|
|
|
// MARK: - Update
|
|
|
|
func updateGoal(
|
|
_ goal: Goal,
|
|
name: String? = nil,
|
|
targetAmount: Decimal? = nil,
|
|
targetDate: Date? = nil,
|
|
clearTargetDate: Bool = false,
|
|
isActive: Bool? = nil,
|
|
category: Category? = nil,
|
|
clearCategory: Bool = false
|
|
) {
|
|
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
|
|
}
|
|
if clearCategory {
|
|
goal.category = nil
|
|
} else if let category = category {
|
|
goal.category = category
|
|
}
|
|
save()
|
|
}
|
|
|
|
func deleteGoal(_ goal: Goal) {
|
|
context.delete(goal)
|
|
save()
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
private func save() {
|
|
guard context.hasChanges else { return }
|
|
do {
|
|
try context.save()
|
|
// Note: Removed redundant fetchGoals() call - the NotificationCenter observer
|
|
// in setupNotificationObserver() already handles refetching on context changes
|
|
} catch {
|
|
print("Failed to save goals: \(error)")
|
|
}
|
|
}
|
|
}
|