Files
InvestmentTrackerApp/PortfolioJournal/Repositories/InvestmentSourceRepository.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

248 lines
7.9 KiB
Swift

import Foundation
import CoreData
import Combine
@MainActor
class InvestmentSourceRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var sources: [InvestmentSource] = []
private var cancellables = Set<AnyCancellable>()
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
fetchSources()
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.fetchSources()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchSources() }
.store(in: &cancellables)
}
// MARK: - Fetch
func fetchSources(account: Account? = nil) {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
if let account = account {
request.predicate = NSPredicate(format: "account == %@", account)
}
request.sortDescriptors = [
NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)
]
// Performance: Add batch size for large datasets
request.fetchBatchSize = 100
do {
sources = try context.fetch(request)
} catch {
print("Failed to fetch sources: \(error)")
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
/// Check if a source with the given name already exists in the account (case-insensitive)
func sourceExists(name: String, in account: Account?) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return sources.contains { source in
let nameMatches = source.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = source.account?.id == account?.id
return nameMatches && accountMatches
}
}
/// Find an existing source by name (case-insensitive) within the same account
func findSource(byName name: String, in account: Account?) -> InvestmentSource? {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return sources.first { source in
let nameMatches = source.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
let accountMatches = source.account?.id == account?.id
return nameMatches && accountMatches
}
}
@discardableResult
func createSource(
name: String,
category: Category,
notificationFrequency: NotificationFrequency = .monthly,
customFrequencyMonths: Int = 1,
account: Account? = nil
) -> InvestmentSource {
// Check if source with same name already exists in this account
if let existing = findSource(byName: name, in: account) {
return existing
}
let source = InvestmentSource(context: context)
source.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
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) {
if source.managedObjectContext == context {
context.delete(source)
} else {
let objectInContext = context.object(with: source.objectID)
context.delete(objectInContext)
}
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()
// Note: Removed redundant fetchSources() 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 InvestmentSource }) {
return true
}
}
}
return false
}
}