- 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
179 lines
7.2 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|