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>
216 lines
7.3 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|