mycode/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
songyc macbook 6880983b54 feat(watch): per-goal opt-in filter for complication list
- 목표 편집에 '애플워치에서 보기' 토글 추가 (iPhone 전용 — 워치가 iPhone과 페어링되므로 iPad에서는 숨김·무의미, 기본 꺼짐) — 목표·다짐이 늘면 컴플리케이션 목록이 폭증하는 문제의 옵트인 해법 (사용자 요청)
- 저장은 기기 로컬 LocalPrefs `local.watchGoals` (§15-3 노출 설정 원칙, pinnedGoals 선례) — CloudKit 스키마 무변경·페이로드 무변경으로 리스크 최소화
- 필터는 WatchSyncManager.makeSnapshot 한 곳: 스냅숏의 목표 = 진행 중 ∩ 체크됨 → 목록(recommendations)·표시 모두 자동 반영, 완료 목표는 기존 진행 중 조건으로 자동 제외. 행동·측정 중 상태(워치 앱 화면·현재 현황·iPad 기록의 동기화 경로)는 필터와 무관 — 지연 추가 0
- 검증(페어링 시뮬레이터 E2E): -watchGoals 1 → 워치 컴플리케이션에 토익 목표+영어 공부 다짐만 표시, -watchGoals 0 → '목표 없음'/'다짐 없음' 빈 상태로 우아하게 처리(크래시 없음), 현재 현황(독서 타이머)은 필터와 무관하게 정상 수신. 아이폰 편집기 토글 렌더 확인. Debug·Store·워치 3스킴 빌드 성공
- 도움말 컴플리케이션 항목 갱신 + 신규 문구 en/ja 완비(카탈로그 766키 클린), §14에 -watchGoals 인자, §5.2/§6.4/§11 문서화
- 주의: 기본 꺼짐이라 업데이트 직후 기존에 페이스에 올린 목표·다짐 컴플리케이션은 빈 상태가 됨 — 목표 편집에서 원하는 목표를 한 번 켜 주면 복구

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

256 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)]))) ?? []
)
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),
isFavorite: action.isFavorite
)
}
// (· ) ' ' .
// (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,
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
}
/// ( )
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()
}
}
}