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:
@@ -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() ?? [:])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user