Sync monthly contributions y allocation targets vía iCloud (CoreData)
Ambos vivían en UserDefaults (device-local, no sincroniza). Migrados a atributos CoreData en entidades ya syncable=YES, así que ahora sincronizan por CloudKit: - InvestmentSource.monthlyContribution (Decimal, opcional) - Category.allocationTarget (Double, opcional) Los stores mantienen su API por UUID (0 cambios en call sites) pero ahora leen/escriben del atributo CoreData vía fetch por id. migrateIfNeeded(context:) mueve una sola vez los valores del diccionario UserDefaults legacy al modelo y borra la clave; enganchado en CoreDataStack.loadPersistentStores junto a MonthlyCheckInStore. DashboardLayoutStore se mantiene en UserDefaults a propósito (preferencia de UI por dispositivo: el columnSpan solo aplica en iPad). NOTA: requiere desplegar el schema CloudKit a producción (CD_monthlyContribution, CD_allocationTarget) antes de que sincronice en TestFlight/App Store. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
@@ -14,6 +14,7 @@ public class Category: NSManagedObject, Identifiable {
|
||||
@NSManaged public var icon: String
|
||||
@NSManaged public var sortOrder: Int16
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var allocationTarget: NSNumber?
|
||||
@NSManaged public var sources: NSSet?
|
||||
|
||||
public override func awakeFromInsert() {
|
||||
|
||||
@@ -12,6 +12,7 @@ public class InvestmentSource: NSManagedObject, Identifiable {
|
||||
@NSManaged public var notificationFrequency: String
|
||||
@NSManaged public var customFrequencyMonths: Int16
|
||||
@NSManaged public var isActive: Bool
|
||||
@NSManaged public var monthlyContribution: NSDecimalNumber?
|
||||
@NSManaged public var createdAt: Date
|
||||
@NSManaged public var category: Category?
|
||||
@NSManaged public var account: Account?
|
||||
|
||||
+2
@@ -25,6 +25,7 @@
|
||||
<relationship name="sources" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="InvestmentSource" inverseName="account" inverseEntity="InvestmentSource"/>
|
||||
</entity>
|
||||
<entity name="Category" representedClassName="Category" syncable="YES">
|
||||
<attribute name="allocationTarget" optional="YES" attributeType="Double" usesScalarValueType="NO"/>
|
||||
<attribute name="colorHex" attributeType="String" defaultValueString="#3B82F6"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="icon" attributeType="String" defaultValueString="chart.pie.fill"/>
|
||||
@@ -38,6 +39,7 @@
|
||||
<attribute name="customFrequencyMonths" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
|
||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="isActive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
|
||||
<attribute name="monthlyContribution" optional="YES" attributeType="Decimal"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="notificationFrequency" attributeType="String" defaultValueString="monthly"/>
|
||||
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="sources" inverseEntity="Account"/>
|
||||
|
||||
@@ -187,6 +187,9 @@ class CoreDataStack: ObservableObject {
|
||||
DispatchQueue.main.async {
|
||||
self?.isLoaded = true
|
||||
MonthlyCheckInStore.migrateIfNeeded()
|
||||
// Migrate device-local UserDefaults data into Core Data so it syncs via iCloud.
|
||||
MonthlyContributionStore.migrateIfNeeded(context: container.viewContext)
|
||||
AllocationTargetStore.migrateIfNeeded(context: container.viewContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,65 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Per-category allocation target (percentage).
|
||||
///
|
||||
/// Backed by the `Category.allocationTarget` Core Data attribute so targets sync across
|
||||
/// devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is device-local
|
||||
/// and did NOT sync — `migrateIfNeeded(context:)` moves any legacy values over.
|
||||
enum AllocationTargetStore {
|
||||
private static let targetsKey = "allocationTargets"
|
||||
/// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
|
||||
private static let legacyKey = "allocationTargets"
|
||||
|
||||
private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
|
||||
|
||||
static func target(for categoryId: UUID) -> Double? {
|
||||
loadTargets()[categoryId.uuidString]
|
||||
guard let category = fetchCategory(categoryId),
|
||||
let target = category.allocationTarget?.doubleValue,
|
||||
target > 0 else { return nil }
|
||||
return target
|
||||
}
|
||||
|
||||
static func setTarget(_ value: Double?, for categoryId: UUID) {
|
||||
var targets = loadTargets()
|
||||
let key = categoryId.uuidString
|
||||
guard let category = fetchCategory(categoryId) else { return }
|
||||
if let value, value > 0 {
|
||||
targets[key] = value
|
||||
category.allocationTarget = NSNumber(value: value)
|
||||
} else {
|
||||
targets.removeValue(forKey: key)
|
||||
category.allocationTarget = nil
|
||||
}
|
||||
saveTargets(targets)
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
static func totalTargetPercentage(for categoryIds: [UUID]) -> Double {
|
||||
let targets = loadTargets()
|
||||
return categoryIds.reduce(0) { total, id in
|
||||
total + (targets[id.uuidString] ?? 0)
|
||||
categoryIds.reduce(0) { total, id in
|
||||
total + (target(for: id) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadTargets() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: targetsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
private static func fetchCategory(_ id: UUID) -> Category? {
|
||||
let request = Category.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
private static func saveTargets(_ targets: [String: Double]) {
|
||||
if let data = try? JSONEncoder().encode(targets) {
|
||||
UserDefaults.standard.set(data, forKey: targetsKey)
|
||||
// MARK: - Migration
|
||||
|
||||
/// One-time migration of legacy UserDefaults targets into Core Data so they sync via
|
||||
/// iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
|
||||
static func migrateIfNeeded(context: NSManagedObjectContext) {
|
||||
guard let data = UserDefaults.standard.data(forKey: legacyKey),
|
||||
let dict = try? JSONDecoder().decode([String: Double].self, from: data),
|
||||
!dict.isEmpty else { return }
|
||||
|
||||
let categories = (try? context.fetch(Category.fetchRequest())) ?? []
|
||||
var changed = false
|
||||
for category in categories where category.allocationTarget == nil {
|
||||
if let value = dict[category.id.uuidString], value > 0 {
|
||||
category.allocationTarget = NSNumber(value: value)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed { try? context.save() }
|
||||
UserDefaults.standard.removeObject(forKey: legacyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,59 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
/// Per-source recurring monthly contribution amount.
|
||||
///
|
||||
/// Backed by the `InvestmentSource.monthlyContribution` Core Data attribute so the value
|
||||
/// syncs across devices via iCloud/CloudKit. Previously stored in `UserDefaults`, which is
|
||||
/// device-local and did NOT sync — `migrateIfNeeded(context:)` moves any legacy value over.
|
||||
enum MonthlyContributionStore {
|
||||
private static let key = "monthlyContributions"
|
||||
/// Legacy UserDefaults key (pre-iCloud). Only read once during migration.
|
||||
private static let legacyKey = "monthlyContributions"
|
||||
|
||||
private static var viewContext: NSManagedObjectContext { CoreDataStack.shared.viewContext }
|
||||
|
||||
static func contribution(for sourceId: UUID) -> Decimal? {
|
||||
let dict = load()
|
||||
guard let raw = dict[sourceId.uuidString] else { return nil }
|
||||
return Decimal(raw)
|
||||
guard let source = fetchSource(sourceId),
|
||||
let amount = source.monthlyContribution?.decimalValue,
|
||||
amount > 0 else { return nil }
|
||||
return amount
|
||||
}
|
||||
|
||||
static func setContribution(_ amount: Decimal?, for sourceId: UUID) {
|
||||
var dict = load()
|
||||
guard let source = fetchSource(sourceId) else { return }
|
||||
if let amount, amount > 0 {
|
||||
dict[sourceId.uuidString] = NSDecimalNumber(decimal: amount).doubleValue
|
||||
source.monthlyContribution = NSDecimalNumber(decimal: amount)
|
||||
} else {
|
||||
dict.removeValue(forKey: sourceId.uuidString)
|
||||
source.monthlyContribution = nil
|
||||
}
|
||||
save(dict)
|
||||
try? viewContext.save()
|
||||
}
|
||||
|
||||
private static func load() -> [String: Double] {
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { return [:] }
|
||||
return decoded
|
||||
private static func fetchSource(_ id: UUID) -> InvestmentSource? {
|
||||
let request = InvestmentSource.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
|
||||
request.fetchLimit = 1
|
||||
return try? viewContext.fetch(request).first
|
||||
}
|
||||
|
||||
private static func save(_ dict: [String: Double]) {
|
||||
if let data = try? JSONEncoder().encode(dict) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
// MARK: - Migration
|
||||
|
||||
/// One-time migration of legacy UserDefaults contributions into Core Data so they sync
|
||||
/// via iCloud. Runs on every launch but is a no-op once the legacy key is cleared.
|
||||
static func migrateIfNeeded(context: NSManagedObjectContext) {
|
||||
guard let data = UserDefaults.standard.data(forKey: legacyKey),
|
||||
let dict = try? JSONDecoder().decode([String: Double].self, from: data),
|
||||
!dict.isEmpty else { return }
|
||||
|
||||
let sources = (try? context.fetch(InvestmentSource.fetchRequest())) ?? []
|
||||
var changed = false
|
||||
for source in sources where source.monthlyContribution == nil {
|
||||
if let raw = dict[source.id.uuidString], raw > 0 {
|
||||
source.monthlyContribution = NSDecimalNumber(value: raw)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed { try? context.save() }
|
||||
UserDefaults.standard.removeObject(forKey: legacyKey)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user