// // WatchSyncManager.swift // Haru_Danim // // Apple Watch 실시간 연동 (CLAUDE.md §9.1) // - 워치의 실행 명령을 받아 DB에 반영하고 최신 스냅숏을 돌려준다 // - 데이터가 바뀔 때마다 updateApplicationContext로 최신 상태를 밀어 넣는다 // import Foundation import SwiftData import WatchConnectivity import WidgetKit @MainActor final class WatchSyncManager: NSObject { static let shared = WatchSyncManager() func activate() { guard WCSession.isSupported() else { return } let session = WCSession.default session.delegate = self session.activate() } // MARK: 스냅숏 생성 /// 현재 DB 상태로 워치용 스냅숏 생성 func makeSnapshot() -> WatchSnapshot { let context = DataStore.shared.mainContext let now = Date.now let math = DayMath() let agg = Aggregator(math: math) var snapshot = WatchSnapshot() snapshot.isPremium = PremiumGate.isUnlocked(.watchApp) let tags = (try? context.fetch(FetchDescriptor( sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] ))) ?? [] snapshot.tags = tags.map { WatchTagInfo(id: $0.uuid, name: $0.name, colorHex: $0.colorHex) } let actions = (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]))) ?? [] snapshot.actions = actions.map { action in let todayValue = agg.todayValue(for: action, now: now) let isCount = action.trackingType == .count return WatchActionInfo( id: action.uuid, name: action.name, symbolName: action.symbolName, colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F", isCount: isCount, todayValue: todayValue, isRunning: action.isRunning, tickingBase: action.isRunning && !isCount ? now.addingTimeInterval(-todayValue) : nil, tagIDs: action.sortedTags.map(\.uuid) ) } let goals = (try? context.fetch(FetchDescriptor( predicate: #Predicate { $0.statusRaw == "inProgress" }, sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] ))) ?? [] snapshot.goals = goals.map { goal in WatchGoalInfo( id: goal.uuid, title: goal.title, symbolName: goal.symbolName, colorHex: goal.colorHex, dayRatio: goal.combinedSpanRatio(.day, math: math, now: now), weekRatio: goal.combinedSpanRatio(.week, math: math, now: now), monthRatio: goal.combinedSpanRatio(.month, math: math, now: now), quests: goal.sortedQuests.map { quest in let progress = QuestProgress(quest: quest, math: math) let day = progress.spanProgress(.day, now: now) let week = progress.spanProgress(.week, now: now) let month = progress.spanProgress(.month, now: now) return WatchQuestInfo( id: quest.uuid, name: quest.targetName, symbolName: quest.targetSymbol, colorHex: quest.targetAction?.sortedTags.first?.colorHex ?? quest.targetTag?.colorHex ?? "#2F6B4F", dayRatio: day.ratio, weekRatio: week.ratio, monthRatio: month.ratio, isAtMost: quest.direction == .atMost, dayAchieved: day.isAchieved, weekAchieved: week.isAchieved, monthAchieved: month.isAchieved ) } ) } // 현재 측정 중 대표 행동 (다이나믹 아일랜드와 동일 기준) let running = (try? context.fetch(FetchDescriptor( predicate: #Predicate { $0.endAt == nil }, sortBy: [SortDescriptor(\.startAt, order: .forward)] ))) ?? [] let mode = LiveActivityMode( rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode) ?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? "" ) ?? .latest if let representative = mode == .earliest ? running.first : running.last, let action = representative.action { snapshot.runningName = action.name snapshot.runningSymbol = action.symbolName snapshot.runningStartAt = representative.startAt snapshot.extraRunningCount = max(0, running.count - 1) } return snapshot } /// 최신 스냅숏을 워치로 전송 (데이터 변경 시마다 호출) func pushSnapshot() { guard WCSession.isSupported() else { return } let session = WCSession.default guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } guard let data = makeSnapshot().encoded() else { return } try? session.updateApplicationContext([WatchSync.snapshotKey: data]) } // MARK: 명령 처리 /// 워치의 명령을 처리하고 최신 스냅숏 응답을 만든다 fileprivate func handle(command: String?, actionID: String?) -> [String: Any] { let context = DataStore.shared.mainContext if command == WatchSync.runCommand, let idString = actionID, let id = UUID(uuidString: idString), PremiumGate.isUnlocked(.watchApp) { let actions = (try? context.fetch(FetchDescriptor())) ?? [] if let action = actions.first(where: { $0.uuid == id }) { ActionRunner.run(action, context: context) try? context.save() LiveActivityManager.sync(context: context) WidgetCenter.shared.reloadAllTimelines() } } guard let data = makeSnapshot().encoded() else { return [:] } return [WatchSync.snapshotKey: data] } } extension WatchSyncManager: WCSessionDelegate { nonisolated func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)? ) { Task { @MainActor in WatchSyncManager.shared.pushSnapshot() } } nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} nonisolated func sessionDidDeactivate(_ session: WCSession) { session.activate() } /// 워치의 실행/새로 고침 요청 (응답으로 최신 스냅숏 반환) nonisolated func session( _ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void ) { let command = message[WatchSync.commandKey] as? String let actionID = message[WatchSync.actionIDKey] as? String Task { @MainActor in let reply = WatchSyncManager.shared.handle(command: command, actionID: actionID) replyHandler(reply) WatchSyncManager.shared.pushSnapshot() } } }