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.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.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() ?? [:]) } } }