App de Apple Watch + 3 complicaciones (build 88) #48

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
This commit is contained in:
alexandrev-tibco
2026-09-13 22:05:31 +02:00
parent 167b736aad
commit 464fbb2bad
38 changed files with 1981 additions and 0 deletions
@@ -0,0 +1,196 @@
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() ?? [:])
}
}
}