- 위젯 QuestCellSnapshot: 분홍 #E0558C 하드코딩 → 지정 색 우선(②③④·잠금 공유 경로) - 워치 스냅숏: 건강 다짐이 초록 폴백이던 잠복 버그까지 함께 수정(지정 색→기본 분홍) - ④ 그리드 실측: 걸음=지정 파랑·나머지=기본 분홍, 3종 빌드 그린. 스키마 무변경 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
291 lines
14 KiB
Swift
291 lines
14 KiB
Swift
//
|
||
// WatchSyncManager.swift
|
||
// Haru_Danim
|
||
//
|
||
// Apple Watch 실시간 연동 (CLAUDE.md §9.1)
|
||
// - 워치의 실행 명령을 받아 DB에 반영하고 최신 스냅숏을 돌려준다
|
||
// - 데이터가 바뀔 때마다 updateApplicationContext로 최신 상태를 밀어 넣는다
|
||
//
|
||
|
||
import Foundation
|
||
import OSLog
|
||
import SwiftData
|
||
import WatchConnectivity
|
||
import WidgetKit
|
||
|
||
private let watchSyncLog = Logger(subsystem: "com.yechan.HaruDanim", category: "WatchSync")
|
||
|
||
@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()
|
||
#if DEBUG
|
||
watchSyncLog.notice("responds userInfo=\(self.responds(to: NSSelectorFromString("session:didReceiveUserInfo:"))) message=\(self.responds(to: NSSelectorFromString("session:didReceiveMessage:replyHandler:")))")
|
||
#endif
|
||
}
|
||
|
||
// 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 = LocalPrefs.orderedActions(
|
||
(try? context.fetch(FetchDescriptor<Action>(sortBy: [SortDescriptor(\.createdAt)]))) ?? []
|
||
)
|
||
// '행동 기록' 컴플리케이션에 옵트인한 행동만 설정 기간의 누적값을 함께 계산한다 (1.5)
|
||
// — 옵트인이 없으면 기간 집계 비용 0 (기존 스냅숏과 동일)
|
||
let complicationPeriods = LocalPrefs.watchActionPeriods()
|
||
snapshot.actions = actions.map { action in
|
||
let todayValue = agg.todayValue(for: action, now: now)
|
||
let isCount = action.trackingType == .count
|
||
var info = 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),
|
||
isFavorite: action.isFavorite
|
||
)
|
||
if let raw = complicationPeriods[action.uuid],
|
||
let period = WatchActionPeriod(rawValue: raw) {
|
||
let range = Self.periodRange(period, math: math, now: now)
|
||
let value = isCount
|
||
? Double(agg.count(for: action, in: range))
|
||
: agg.seconds(for: action, in: range, now: now)
|
||
info.complicationPeriodRaw = raw
|
||
info.periodValue = value
|
||
if info.isRunning && !isCount {
|
||
info.periodTickingBase = now.addingTimeInterval(-value)
|
||
}
|
||
}
|
||
return info
|
||
}
|
||
|
||
// 컴플리케이션(목표·다짐 달성률)에는 진행 중이면서 '애플워치에서 보기'를 켠 목표만 보낸다.
|
||
// 목록(recommendations)·표시 모두 이 스냅숏 기반이라 여기 한 곳이 유일한 필터 지점이고,
|
||
// 행동·측정 중 상태(워치 앱 화면, 현재 현황 컴플리케이션)에는 영향이 없다.
|
||
let goals = ((try? context.fetch(FetchDescriptor<Goal>(
|
||
predicate: #Predicate { $0.statusRaw == "inProgress" },
|
||
sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
|
||
))) ?? [])
|
||
.filter { LocalPrefs.showsOnWatch($0.uuid) }
|
||
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,
|
||
// 건강 다짐: 지정 색 → 기본 분홍(#E0558C) — 위젯 스냅숏과 같은 규칙 (1.5(8)
|
||
// 실측: 예전 폴백은 초록이라 워치에서 건강 다짐이 일반 다짐처럼 보였음)
|
||
colorHex: quest.targetAction?.sortedTags.first?.colorHex
|
||
?? quest.targetTag?.colorHex
|
||
?? (quest.isHealthQuest
|
||
? (quest.healthColorHex.isEmpty ? "#E0558C" : quest.healthColorHex)
|
||
: "#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.runningActionID = action.uuid
|
||
snapshot.extraRunningCount = max(0, running.count - 1)
|
||
}
|
||
|
||
return snapshot
|
||
}
|
||
|
||
/// '행동 기록' 컴플리케이션 표시 기간의 집계 범위 (1.5).
|
||
/// 롤링(지난 7일/30일)은 통계 '오늘 기준' 보기(1.4)의 rollingRange 재사용 —
|
||
/// 수치는 통계 탭과 동일해야 하므로 반드시 DayMath 경유
|
||
private static func periodRange(_ period: WatchActionPeriod, math: DayMath, now: Date) -> Range<Date> {
|
||
switch period {
|
||
case .day: return math.dayRange(containing: now)
|
||
case .week: return math.weekRange(containing: now)
|
||
case .month: return math.monthRange(containing: now)
|
||
case .last7: return math.rollingRange(endingAtKey: math.dayKey(for: now), days: 7)
|
||
case .last30: return math.rollingRange(endingAtKey: math.dayKey(for: now), days: 30)
|
||
}
|
||
}
|
||
|
||
/// 컴플리케이션 전용 전송의 최근 상태 (하루 전송 예산이 있어 절약해서 보냄)
|
||
private var lastComplicationStateKey = ""
|
||
private var lastComplicationPushAt = Date.distantPast
|
||
|
||
/// 디바운스 중인 전송 작업 (연타 대비)
|
||
private var pushTask: Task<Void, Never>?
|
||
|
||
/// 최신 스냅숏을 워치로 전송 (데이터 변경 시마다 호출).
|
||
/// 스냅숏 생성은 모든 목표×다짐×3기간 진행률을 집계하는 비싼 작업이라,
|
||
/// 짧은 디바운스로 연타(횟수 버튼 연속 탭 등) 중의 중간 계산을 건너뛰고
|
||
/// 마지막 상태 한 번만 만들어 보낸다.
|
||
func pushSnapshot() {
|
||
pushTask?.cancel()
|
||
pushTask = Task { @MainActor in
|
||
try? await Task.sleep(for: .milliseconds(350))
|
||
guard !Task.isCancelled else { return }
|
||
self.pushSnapshotNow()
|
||
}
|
||
}
|
||
|
||
private func pushSnapshotNow() {
|
||
guard WCSession.isSupported() else { return }
|
||
let session = WCSession.default
|
||
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
|
||
let snapshot = makeSnapshot()
|
||
guard let data = snapshot.encoded() else { return }
|
||
// applicationContext: 워치 앱이 다음에 열릴 때 확실히 전달되는 기본 채널
|
||
try? session.updateApplicationContext([WatchSync.snapshotKey: data])
|
||
pushForComplicationIfNeeded(session: session, snapshot: snapshot, data: data)
|
||
}
|
||
|
||
/// 워치 앱이 꺼져 있어도 컴플리케이션이 갱신되도록 전용 채널로 전송.
|
||
/// 시스템이 하루 전송 횟수를 제한하므로 측정 상태가 바뀌었거나 30분이 지났을 때만 보낸다
|
||
private func pushForComplicationIfNeeded(session: WCSession, snapshot: WatchSnapshot, data: Data) {
|
||
guard session.isComplicationEnabled else { return }
|
||
// 시작 시각도 상태에 포함 — 같은 행동을 종료 직후(디바운스 안에) 다시 시작해도
|
||
// 타이머 기준 시각이 바뀐 것을 새 상태로 인식해 컴플리케이션이 갱신되게 한다
|
||
let startStamp = snapshot.runningStartAt.map { Int($0.timeIntervalSince1970) } ?? 0
|
||
let stateKey = "\(snapshot.runningName ?? "")|\(startStamp)|\(snapshot.extraRunningCount)|\(snapshot.isPremium)"
|
||
let now = Date.now
|
||
guard stateKey != lastComplicationStateKey
|
||
|| now.timeIntervalSince(lastComplicationPushAt) > 30 * 60 else { return }
|
||
lastComplicationStateKey = stateKey
|
||
lastComplicationPushAt = now
|
||
session.transferCurrentComplicationUserInfo([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) {
|
||
if PremiumGate.isUnlocked(.watchApp) {
|
||
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
|
||
if let action = actions.first(where: { $0.uuid == id }) {
|
||
watchSyncLog.notice("run command: \(action.name, privacy: .public)")
|
||
ActionRunner.run(action, context: context)
|
||
try? context.save()
|
||
LiveActivityManager.sync(context: context)
|
||
WidgetCenter.shared.reloadAllTimelines()
|
||
// 제어 센터 컨트롤의 토글 상태도 함께 갱신 (DataChange.commit과 동일한 경로 유지)
|
||
ControlCenter.shared.reloadAllControls()
|
||
} else {
|
||
watchSyncLog.error("run command: action not found \(idString, privacy: .public)")
|
||
}
|
||
} else {
|
||
watchSyncLog.error("run command ignored: watchApp premium locked")
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
}
|
||
|
||
/// sendMessage 실패 시 워치가 큐 채널로 재전송한 실행 명령 (백그라운드 전달 보장)
|
||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||
let command = userInfo[WatchSync.commandKey] as? String
|
||
let actionID = userInfo[WatchSync.actionIDKey] as? String
|
||
watchSyncLog.notice("didReceiveUserInfo command=\(command ?? "nil", privacy: .public)")
|
||
guard command == WatchSync.runCommand else { return }
|
||
// 큐에 오래 머문 명령이 뒤늦게 실행돼 엉뚱한 토글을 일으키지 않도록 유효 시간 확인
|
||
if let sentAt = userInfo[WatchSync.sentAtKey] as? TimeInterval,
|
||
Date.now.timeIntervalSince1970 - sentAt > WatchSync.queuedRunMaxAge {
|
||
return
|
||
}
|
||
Task { @MainActor in
|
||
_ = WatchSyncManager.shared.handle(command: command, actionID: actionID)
|
||
WatchSyncManager.shared.pushSnapshot()
|
||
}
|
||
}
|
||
}
|