mycode/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
songyc macbook f9f1898d15 feat(watch): 애플워치 앱 + WCSession 실시간 연동 (CLAUDE.md §9.1)
- WatchShared/WatchPayload: iOS·워치·워치위젯 3개 타깃이 공유하는 Codable 스냅숏
  (꼬리표/행동/목표·다짐 진행률/측정 중 대표 행동 + 추가 개수/프리미엄)
- iOS WatchSyncManager: 워치의 실행·새로 고침 명령 처리(ActionRunner 공용) 후
  Live Activity·홈 위젯 갱신, 데이터 변경 시 updateApplicationContext로 푸시
- 워치 앱: 꼬리표 목록 → 행동 목록 → 탭으로 실행(시간형 토글/횟수형 +1),
  측정 중 실시간 타이머, 대표 행동 표시는 다이나믹 아일랜드 설정과 동일 기준
- 미결제 시 프리미엄 잠금, iPhone 연결 불가 안내
- 수신 스냅숏을 워치 App Group에 캐시 (컴플리케이션용)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 09:00:01 +09:00

179 lines
7.2 KiB
Swift

//
// 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<Tag>(
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<Action>(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<Goal>(
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<TimeSession>(
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<Action>())) ?? []
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()
}
}
}