Files
InvestmentTrackerApp/PortfolioJournal/Repositories/CategoryRepository.swift
T
alexandrev-tibco 773da6800b Release 1.3.1 (build 20): iCloud sync fix + sources search/filter
iCloud sync:
- Fix: deploy CloudKit schema to production so exports work
- Fix: PredictionCache marked syncable=NO (binary attr caused partial failures)
- Fix: forceExportToiCloud uses createdAt+1ms for guaranteed persistent history
- Fix: auto-deduplication on CloudKit import (processRemoteChanges)
- Add: NSPersistentHistoryTrackingKey always enabled regardless of CloudKit state
- Add: cloudKitForceReload notification so repositories re-fetch on remote changes

Sources UI:
- Add: horizontal category filter chips in SourceListView
- Add: search toggle button with animated TextField

Other:
- Add: ITSAppUsesNonExemptEncryption=NO in Info.plist (skip manual compliance)
- Add: fastlane submit lane with run_precheck_before_submit=false
- Update: release notes all locales for 1.3.1
- Update: fastlane API key via pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:19:33 +02:00

217 lines
6.5 KiB
Swift

import Foundation
import CoreData
import Combine
@MainActor
class CategoryRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var categories: [Category] = []
private var cancellables = Set<AnyCancellable>()
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)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchCategories() }
.store(in: &cancellables)
}
// MARK: - Fetch
func fetchCategories() {
let request: NSFetchRequest<Category> = 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> = 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<NSManagedObject> {
if objects.contains(where: { $0 is Category }) {
return true
}
}
}
return false
}
}