773da6800b
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>
248 lines
7.9 KiB
Swift
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
|
|
}
|
|
}
|