464fbb2bad
App de watchOS de solo consulta: resumen de cartera (total, cambio del mes y mayores posiciones), objetivos con su progreso y estado del check-in con la racha. Toda la edición sigue en el iPhone. Complicaciones (accessoryCircular / Rectangular / Inline / Corner): valor total de la cartera, progreso del objetivo más cercano a cumplirse y racha + estado del check-in. Datos: los App Groups no se comparten entre iOS y watchOS, así que el reloj no puede leer el store de CoreData que lee el widget de iOS. En su lugar, WatchSyncService construye un WatchPortfolioSnapshot ligero y lo envía como application context de WatchConnectivity, enganchado a CoreDataStack.refreshWidgetData() — el mismo punto que refresca el widget de iOS, así que cualquier repositorio que toque datos refresca el reloj. WatchDataStore lo cachea en el App Group del watch (WatchSnapshotCache) y recarga las timelines; la extensión de complicaciones solo lee ese caché. Proyecto: dos targets nuevos (watchOS app + widget extension) con grupos sincronizados de Xcode 16; Shared/ pertenece a la app de watch y se comparte con la app de iOS y el widget mediante exception sets, el mismo mecanismo que ya usaba el widget de iOS para el modelo de CoreData. Scheme compartido PortfolioJournalWatch. Localización propia por target en los 7 idiomas. Verificado: compilan los tres targets, la app de watch queda embebida en Watch/ con la extensión en PlugIns/, y en el par de simuladores iPhone 17 Pro Max + Apple Watch Series 11 el reloj recibe y muestra el snapshot enviado por el iPhone. 10 tests nuevos del payload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
197 lines
7.3 KiB
Swift
197 lines
7.3 KiB
Swift
import Foundation
|
|
import CoreData
|
|
import WatchConnectivity
|
|
|
|
/// Ships a `WatchPortfolioSnapshot` from the iPhone to the paired Apple Watch.
|
|
///
|
|
/// The watch app and its complications are read-only, so a single application
|
|
/// context — always the latest state, coalesced by the system — is enough. The
|
|
/// watch may also ask for a fresh copy (`WatchSyncMessage.requestSnapshot`) when it opens
|
|
/// while the phone is reachable.
|
|
@MainActor
|
|
final class WatchSyncService: NSObject {
|
|
|
|
static let shared = WatchSyncService()
|
|
|
|
/// Largest holdings sent to the watch. A watch screen shows a handful at
|
|
/// most, and the payload must stay small.
|
|
private let maxSources = 6
|
|
private let maxGoals = 5
|
|
|
|
private var session: WCSession? {
|
|
WCSession.isSupported() ? WCSession.default : nil
|
|
}
|
|
|
|
/// Last payload actually handed to WatchConnectivity, so repeated data
|
|
/// changes that produce an identical snapshot don't queue redundant work.
|
|
private var lastSentSnapshot: WatchPortfolioSnapshot?
|
|
private var pendingSync = false
|
|
|
|
private override init() {
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func activate() {
|
|
guard let session else { return }
|
|
session.delegate = self
|
|
if session.activationState != .activated {
|
|
session.activate()
|
|
}
|
|
}
|
|
|
|
/// Coalesces the bursts of Core Data changes a single user action produces
|
|
/// (save snapshot → repository refresh → widget refresh) into one send.
|
|
func scheduleSync() {
|
|
guard session != nil, !pendingSync else { return }
|
|
pendingSync = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.pendingSync = false
|
|
self?.syncNow()
|
|
}
|
|
}
|
|
|
|
func syncNow() {
|
|
guard let session, session.activationState == .activated else { return }
|
|
let snapshot = buildSnapshot()
|
|
guard snapshot != lastSentSnapshot else { return }
|
|
guard let context = snapshot.applicationContext() else { return }
|
|
|
|
do {
|
|
try session.updateApplicationContext(context)
|
|
lastSentSnapshot = snapshot
|
|
} catch {
|
|
// Not fatal: the watch keeps its cached copy and can pull a fresh
|
|
// one next time it opens with the phone reachable.
|
|
print("Watch sync failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Snapshot building
|
|
|
|
func buildSnapshot(referenceDate: Date = Date()) -> WatchPortfolioSnapshot {
|
|
let context = CoreDataStack.shared.viewContext
|
|
|
|
let sourceRequest: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
|
|
sourceRequest.predicate = NSPredicate(format: "isActive == YES")
|
|
let sources = (try? context.fetch(sourceRequest)) ?? []
|
|
|
|
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
|
|
// Value at the close of the previous month, per source, so the change
|
|
// shown on the watch matches the dashboard's month change.
|
|
let previousMonthEnd = referenceDate.startOfMonth.adding(months: -1).endOfMonth
|
|
let previousTotal = sources.reduce(Decimal.zero) { partial, source in
|
|
let snapshot = source.sortedSnapshotsByDateAscending.last { $0.date <= previousMonthEnd }
|
|
return partial + (snapshot?.value?.decimalValue ?? .zero)
|
|
}
|
|
let monthChange = totalValue - previousTotal
|
|
let monthChangePercent: Double = previousTotal > 0
|
|
? NSDecimalNumber(decimal: monthChange / previousTotal).doubleValue
|
|
: 0
|
|
|
|
let topSources = sources
|
|
.sorted { $0.latestValue > $1.latestValue }
|
|
.prefix(maxSources)
|
|
.map { source in
|
|
WatchPortfolioSnapshot.Source(
|
|
id: source.id,
|
|
name: source.name,
|
|
categoryName: source.category?.name,
|
|
categoryColorHex: source.category?.colorHex,
|
|
value: NSDecimalNumber(decimal: source.latestValue).doubleValue
|
|
)
|
|
}
|
|
|
|
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
|
|
goalRequest.predicate = NSPredicate(format: "isActive == YES")
|
|
let goals = (try? context.fetch(goalRequest)) ?? []
|
|
let watchGoals = goals
|
|
.map { goal -> WatchPortfolioSnapshot.Goal in
|
|
let current = goal.relevantSources(from: sources)
|
|
.reduce(Decimal.zero) { $0 + $1.latestValue }
|
|
return WatchPortfolioSnapshot.Goal(
|
|
id: goal.id,
|
|
name: goal.name,
|
|
categoryName: goal.category?.name,
|
|
categoryIcon: goal.category?.icon,
|
|
categoryColorHex: goal.category?.colorHex,
|
|
currentValue: NSDecimalNumber(decimal: current).doubleValue,
|
|
targetValue: NSDecimalNumber(decimal: goal.targetDecimal).doubleValue
|
|
)
|
|
}
|
|
.sorted { $0.progress > $1.progress }
|
|
.prefix(maxGoals)
|
|
|
|
let stats = MonthlyCheckInStore.stats(referenceDate: referenceDate)
|
|
let dueMonth = MonthlyCheckInStore.effectiveMonth(
|
|
for: referenceDate,
|
|
relativeTo: referenceDate,
|
|
graceDays: MonthlyCheckInStore.graceDays
|
|
)
|
|
let checkInDone = MonthlyCheckInStore.completionDate(for: dueMonth) != nil
|
|
|
|
return WatchPortfolioSnapshot(
|
|
generatedAt: referenceDate,
|
|
currencyCode: AppSettings.getOrCreate(in: context).currency,
|
|
totalValue: NSDecimalNumber(decimal: totalValue).doubleValue,
|
|
monthChange: NSDecimalNumber(decimal: monthChange).doubleValue,
|
|
monthChangePercent: monthChangePercent,
|
|
sources: Array(topSources),
|
|
goals: Array(watchGoals),
|
|
currentStreak: stats.currentStreak,
|
|
bestStreak: stats.bestStreak,
|
|
checkInDone: checkInDone,
|
|
checkInMonthLabel: dueMonth.monthYearString
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - WCSessionDelegate
|
|
|
|
extension WatchSyncService: WCSessionDelegate {
|
|
|
|
nonisolated func session(
|
|
_ session: WCSession,
|
|
activationDidCompleteWith activationState: WCSessionActivationState,
|
|
error: Error?
|
|
) {
|
|
guard activationState == .activated else { return }
|
|
Task { @MainActor in
|
|
self.syncNow()
|
|
}
|
|
}
|
|
|
|
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
|
|
|
/// The user switched to a different watch: reactivate so the new one gets
|
|
/// its own context.
|
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
|
session.activate()
|
|
}
|
|
|
|
nonisolated func sessionWatchStateDidChange(_ session: WCSession) {
|
|
Task { @MainActor in
|
|
self.lastSentSnapshot = nil
|
|
self.syncNow()
|
|
}
|
|
}
|
|
|
|
nonisolated func session(
|
|
_ session: WCSession,
|
|
didReceiveMessage message: [String: Any],
|
|
replyHandler: @escaping ([String: Any]) -> Void
|
|
) {
|
|
guard message[WatchSyncMessage.requestSnapshot] != nil else {
|
|
replyHandler([:])
|
|
return
|
|
}
|
|
Task { @MainActor in
|
|
let snapshot = self.buildSnapshot()
|
|
self.lastSentSnapshot = snapshot
|
|
replyHandler(snapshot.applicationContext() ?? [:])
|
|
}
|
|
}
|
|
}
|