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

216 lines
7.3 KiB
Swift

import Foundation
import CoreData
import Combine
@MainActor
class AccountRepository: ObservableObject {
private let context: NSManagedObjectContext
@Published private(set) var accounts: [Account] = []
private var cancellables = Set<AnyCancellable>()
init(context: NSManagedObjectContext = CoreDataStack.shared.viewContext) {
self.context = context
fetchAccounts()
setupNotificationObserver()
}
private func setupNotificationObserver() {
NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.fetchAccounts()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .cloudKitForceReload)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.fetchAccounts() }
.store(in: &cancellables)
}
// MARK: - Fetch
func fetchAccounts() {
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
]
// Performance: Add batch size for fetches
request.fetchBatchSize = 50
do {
accounts = try context.fetch(request)
} catch {
print("Failed to fetch accounts: \(error)")
accounts = []
}
}
func fetchAccount(by id: UUID) -> Account? {
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
request.fetchLimit = 1
return try? context.fetch(request).first
}
// MARK: - Create
@discardableResult
func createAccount(
name: String,
currency: String?,
inputMode: InputMode,
notificationFrequency: NotificationFrequency,
customFrequencyMonths: Int = 1
) -> Account {
let account = Account(context: context)
account.name = name
account.currency = currency
account.inputMode = inputMode.rawValue
account.notificationFrequency = notificationFrequency.rawValue
account.customFrequencyMonths = Int16(customFrequencyMonths)
account.sortOrder = Int16(accounts.count)
save()
return account
}
func createDefaultAccountIfNeeded() -> Account {
// Fetch accounts directly from database to avoid race condition with async fetch
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Account.sortOrder, ascending: true),
NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)
]
let existingAccounts = (try? context.fetch(request)) ?? []
// Check if Default account already exists
if let defaultAccount = existingAccounts.first(where: { $0.isDefaultAccount }) {
return defaultAccount
}
// If no Default account but other accounts exist, return first one
if let existing = existingAccounts.first {
return existing
}
// No accounts exist, create Default account
let defaultCurrency = AppSettings.getOrCreate(in: context).currency
let account = Account(context: context)
account.name = Account.defaultAccountName
account.currency = defaultCurrency
account.inputMode = InputMode.simple.rawValue
account.notificationFrequency = NotificationFrequency.monthly.rawValue
account.customFrequencyMonths = 1
account.sortOrder = 0
// Attach existing sources to the default account
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
if let sources = try? context.fetch(sourceRequest) {
for source in sources where source.account == nil {
source.account = account
}
}
save()
return account
}
/// Removes duplicate Default accounts, keeping only the oldest one.
/// Call this once to clean up any duplicates created by the race condition bug.
func cleanupDuplicateDefaultAccounts() {
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.predicate = NSPredicate(format: "name == %@", Account.defaultAccountName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)]
guard let defaultAccounts = try? context.fetch(request), defaultAccounts.count > 1 else {
return
}
// Keep the first (oldest) Default account, delete the rest
let accountsToDelete = defaultAccounts.dropFirst()
for account in accountsToDelete {
// Move sources to the kept Default account before deleting
if let keptAccount = defaultAccounts.first {
for source in account.sourcesArray {
source.account = keptAccount
}
}
context.delete(account)
}
save()
}
// MARK: - Validation
func isNameAvailable(_ name: String, excludingAccountId: UUID? = nil) -> Bool {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
return !accounts.contains { account in
if let excludeId = excludingAccountId, account.safeId == excludeId {
return false
}
return account.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
}
// MARK: - Update
func updateAccount(
_ account: Account,
name: String? = nil,
currency: String? = nil,
inputMode: InputMode? = nil,
notificationFrequency: NotificationFrequency? = nil,
customFrequencyMonths: Int? = nil
) {
if let name = name {
account.name = name
}
if let currency = currency {
account.currency = currency
}
if let inputMode = inputMode {
account.inputMode = inputMode.rawValue
}
if let notificationFrequency = notificationFrequency {
account.notificationFrequency = notificationFrequency.rawValue
}
if let customMonths = customFrequencyMonths {
account.customFrequencyMonths = Int16(customMonths)
}
save()
}
// MARK: - Delete
func canDeleteAccount(_ account: Account) -> Bool {
// Cannot delete the Default account
guard !account.isDefaultAccount else { return false }
// Must keep at least one account
return accounts.count > 1
}
func deleteAccount(_ account: Account) {
guard canDeleteAccount(account) else { return }
context.delete(account)
save()
}
// MARK: - Save
private func save() {
guard context.hasChanges else { return }
do {
try context.save()
// Note: Removed redundant fetchAccounts() call - the NotificationCenter observer
// in setupNotificationObserver() already handles refetching on context changes
} catch {
print("Failed to save accounts: \(error)")
}
}
}