import Foundation import CoreData import Combine @MainActor class CategoryRepository: ObservableObject { private let context: NSManagedObjectContext @Published private(set) var categories: [Category] = [] private var cancellables = Set() init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) { self.context = context fetchCategories() ensureDefaultCategoriesExist() setupNotificationObserver() } private func setupNotificationObserver() { NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context) .receive(on: DispatchQueue.main) .sink { [weak self] notification in guard let self, self.isRelevantChange(notification) else { return } self.fetchCategories() } .store(in: &cancellables) } // MARK: - Fetch func fetchCategories() { let request: NSFetchRequest = Category.fetchRequest() request.sortDescriptors = [ NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true), NSSortDescriptor(keyPath: \Category.name, ascending: true) ] // Performance: Add batch size for fetches request.fetchBatchSize = 50 do { categories = try context.fetch(request) } catch { print("Failed to fetch categories: \(error)") categories = [] } } func fetchCategory(by id: UUID) -> Category? { let request: NSFetchRequest = Category.fetchRequest() request.predicate = NSPredicate(format: "id == %@", id as CVarArg) request.fetchLimit = 1 return try? context.fetch(request).first } // MARK: - Create /// Check if a category with the given name already exists (case-insensitive) func categoryExists(name: String) -> Bool { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = trimmed.lowercased() return categories.contains { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized } } /// Find an existing category by name (case-insensitive) func findCategory(byName name: String) -> Category? { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = trimmed.lowercased() return categories.first { $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized } } @discardableResult func createCategory( name: String, colorHex: String, icon: String ) -> Category { // Check if category with same name already exists if let existing = findCategory(byName: name) { return existing } let category = Category(context: context) category.name = name.trimmingCharacters(in: .whitespacesAndNewlines) category.colorHex = colorHex category.icon = icon category.sortOrder = Int16(categories.count) save() return category } func createDefaultCategoriesIfNeeded() { ensureDefaultCategoriesExist() } func ensureDefaultCategoriesExist() { var addedCount = 0 var nextSortOrder = categories.count for categoryData in Category.defaultCategories { if categoryExists(name: categoryData.name) { continue } let category = Category(context: context) category.name = categoryData.name category.colorHex = categoryData.colorHex category.icon = categoryData.icon category.sortOrder = Int16(nextSortOrder) nextSortOrder += 1 addedCount += 1 } guard addedCount > 0 else { return } save() 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() // Note: Removed redundant fetchCategories() call - the NotificationCenter observer // in setupNotificationObserver() already handles refetching on context changes 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 { if objects.contains(where: { $0 is Category }) { return true } } } return false } }