From f9f1898d15ae4b1f59eb07e2c28a4ebb63ebdabe Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 09:00:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(watch):=20=EC=95=A0=ED=94=8C=EC=9B=8C?= =?UTF-8?q?=EC=B9=98=20=EC=95=B1=20+=20WCSession=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=97=B0=EB=8F=99=20(CLAUDE.md=20=C2=A79.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WatchShared/WatchPayload: iOS·워치·워치위젯 3개 타깃이 공유하는 Codable 스냅숏 (꼬리표/행동/목표·다짐 진행률/측정 중 대표 행동 + 추가 개수/프리미엄) - iOS WatchSyncManager: 워치의 실행·새로 고침 명령 처리(ActionRunner 공용) 후 Live Activity·홈 위젯 갱신, 데이터 변경 시 updateApplicationContext로 푸시 - 워치 앱: 꼬리표 목록 → 행동 목록 → 탭으로 실행(시간형 토글/횟수형 +1), 측정 중 실시간 타이머, 대표 행동 표시는 다이나믹 아일랜드 설정과 동일 기준 - 미결제 시 프리미엄 잠금, iPhone 연결 불가 안내 - 수신 스냅숏을 워치 App Group에 캐시 (컴플리케이션용) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 ++ .../Assets.xcassets/Contents.json | 6 + .../ContentView.swift | 213 ++++++++++++++++++ .../Haru_DanimWatch Watch App.entitlements | 10 + .../Haru_DanimWatchApp.swift | 17 ++ .../WatchStore.swift | 132 +++++++++++ .../HaruDanim/IOS/Core/WatchSyncManager.swift | 178 +++++++++++++++ .../HaruDanim/WatchShared/WatchPayload.swift | 118 ++++++++++ 9 files changed, 698 insertions(+) create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift create mode 100644 myApp/HaruDanim/IOS/Core/WatchSyncManager.swift create mode 100644 myApp/HaruDanim/WatchShared/WatchPayload.swift diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift new file mode 100644 index 0000000..13ff0e9 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift @@ -0,0 +1,213 @@ +// +// ContentView.swift +// Haru_DanimWatch Watch App +// +// 워치 앱 본체 (CLAUDE.md §9.1) +// 꼬리표 목록 → 행동 목록 → 탭으로 실행 (시간형 토글 / 횟수형 +1) +// + +import SwiftUI + +struct ContentView: View { + @State private var store = WatchStore.shared + + var body: some View { + NavigationStack { + Group { + #if DEBUG + if UserDefaults.standard.bool(forKey: "complicationPreview") { + ComplicationPreviewScreen() + } else { + mainContent + } + #else + mainContent + #endif + } + .navigationTitle("하루다님") + } + .onAppear { store.activate() } + } + + @ViewBuilder + private var mainContent: some View { + if let snapshot = store.snapshot { + if snapshot.isPremium { + tagList(snapshot) + } else { + lockedView + } + } else { + VStack(spacing: 8) { + ProgressView() + Text("iPhone과 연결 중...") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + + private var lockedView: some View { + VStack(spacing: 6) { + Image(systemName: "crown.fill") + .foregroundStyle(.yellow) + Text("프리미엄 기능") + .font(.headline) + Text("iPhone 앱의 설정 → 프리미엄에서 잠금 해제해 주세요") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + // MARK: 꼬리표 목록 + + private func tagList(_ snapshot: WatchSnapshot) -> some View { + List { + if let name = snapshot.runningName { + Section { + HStack(spacing: 6) { + Image(systemName: snapshot.runningSymbol ?? "record.circle") + .foregroundStyle(.yellow) + VStack(alignment: .leading, spacing: 0) { + Text(name) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + if let start = snapshot.runningStartAt { + Text(start, style: .timer) + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + Spacer() + if snapshot.extraRunningCount > 0 { + Text("+\(snapshot.extraRunningCount)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + } + } header: { + Text("측정 중") + } + } + Section { + ForEach(snapshot.tags) { tag in + let actions = snapshot.actions.filter { $0.tagIDs.contains(tag.id) } + if !actions.isEmpty { + NavigationLink { + WatchActionListView(title: tag.name, actions: actions) + } label: { + HStack(spacing: 8) { + Circle() + .fill(Color(watchHex: tag.colorHex)) + .frame(width: 10, height: 10) + Text(tag.name) + .lineLimit(1) + Spacer() + Text("\(actions.count)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + let untagged = snapshot.actions.filter { $0.tagIDs.isEmpty } + if !untagged.isEmpty { + NavigationLink { + WatchActionListView(title: "꼬리표 없음", actions: untagged) + } label: { + HStack(spacing: 8) { + Circle() + .strokeBorder(.secondary, lineWidth: 1) + .frame(width: 10, height: 10) + Text("꼬리표 없음") + Spacer() + Text("\(untagged.count)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } footer: { + if store.lastSendFailed { + Text("iPhone에 연결할 수 없어요. 근처에 있는지 확인해 주세요.") + } + } + } + } +} + +// MARK: - 행동 목록 + +struct WatchActionListView: View { + @State private var store = WatchStore.shared + let title: String + let actions: [WatchActionInfo] + + /// 최신 스냅숏의 상태로 갱신된 행동 목록 (실행 후 반영) + private var currentActions: [WatchActionInfo] { + guard let snapshot = store.snapshot else { return actions } + return actions.compactMap { old in + snapshot.actions.first { $0.id == old.id } ?? old + } + } + + var body: some View { + List { + ForEach(currentActions) { action in + Button { + store.run(action) + } label: { + HStack(spacing: 8) { + Image(systemName: action.symbolName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background( + Color(watchHex: action.colorHex), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + VStack(alignment: .leading, spacing: 0) { + Text(action.name) + .font(.footnote.weight(.semibold)) + .lineLimit(1) + valueText(action) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + if action.isRunning { + Image(systemName: "record.circle") + .font(.caption) + .foregroundStyle(.yellow) + } + } + } + } + } + .navigationTitle(title) + } + + @ViewBuilder + private func valueText(_ action: WatchActionInfo) -> some View { + if action.isCount { + Text("오늘 \(Int(action.todayValue))회") + } else if let base = action.tickingBase { + Text(base, style: .timer) + } else { + Text("오늘 \(WatchFormat.duration(action.todayValue))") + } + } +} + +/// 워치 전용 간단 포매터 +enum WatchFormat { + static func duration(_ seconds: Double) -> String { + let total = Int(seconds.rounded()) + let h = total / 3600 + let m = (total % 3600) / 60 + if h > 0 { return m > 0 ? "\(h)시간 \(m)분" : "\(h)시간" } + if m > 0 { return "\(m)분" } + return "\(total)초" + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements new file mode 100644 index 0000000..64b9130 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.yechan.HaruDanim + + + diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift new file mode 100644 index 0000000..bd97eea --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift @@ -0,0 +1,17 @@ +// +// Haru_DanimWatchApp.swift +// Haru_DanimWatch Watch App +// +// Created by 송예찬 on 7/10/26. +// + +import SwiftUI + +@main +struct Haru_DanimWatch_Watch_AppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift new file mode 100644 index 0000000..fb5bd03 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift @@ -0,0 +1,132 @@ +// +// WatchStore.swift +// Haru_DanimWatch Watch App +// +// iPhone과의 WCSession 연동 상태 저장소 (CLAUDE.md §9.1) +// - iPhone이 보내는 스냅숏을 보관하고, 탭 명령을 iPhone으로 보낸다 +// - 최신 스냅숏은 App Group defaults에 캐시해 컴플리케이션이 읽게 한다 +// + +import Foundation +import Observation +import SwiftUI +import WatchConnectivity +import WidgetKit + +@MainActor +@Observable +final class WatchStore: NSObject { + static let shared = WatchStore() + + private(set) var snapshot: WatchSnapshot? + private(set) var isReachable = false + /// 마지막 명령 전송 실패 여부 (iPhone 연결 안내용) + private(set) var lastSendFailed = false + + static let groupDefaults = UserDefaults(suiteName: "group.com.yechan.HaruDanim") ?? .standard + + func activate() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + // 이전 세션에서 받은 컨텍스트/캐시로 우선 표시 + if let data = session.receivedApplicationContext[WatchSync.snapshotKey] as? Data, + let decoded = WatchSnapshot.decode(data) { + apply(decoded) + } else if let data = Self.groupDefaults.data(forKey: WatchSync.cachedSnapshotKey), + let decoded = WatchSnapshot.decode(data) { + apply(decoded) + } + refresh() + } + + /// iPhone에 최신 상태 요청 + func refresh() { + send([WatchSync.commandKey: WatchSync.refreshCommand]) + } + + /// 행동 실행 (시간형 토글 / 횟수형 +1) + func run(_ action: WatchActionInfo) { + send([ + WatchSync.commandKey: WatchSync.runCommand, + WatchSync.actionIDKey: action.id.uuidString, + ]) + } + + private func send(_ message: [String: Any]) { + let session = WCSession.default + guard session.activationState == .activated else { return } + session.sendMessage(message) { reply in + Task { @MainActor in + self.lastSendFailed = false + if let data = reply[WatchSync.snapshotKey] as? Data, + let decoded = WatchSnapshot.decode(data) { + self.apply(decoded) + } + } + } errorHandler: { _ in + Task { @MainActor in + self.lastSendFailed = true + } + } + } + + fileprivate func apply(_ new: WatchSnapshot) { + // 오래된 스냅숏으로 되돌아가지 않게 생성 시각 비교 + if let current = snapshot, current.generatedAt > new.generatedAt { return } + snapshot = new + lastSendFailed = false + // 컴플리케이션용 캐시 + 타임라인 갱신 + if let data = new.encoded() { + Self.groupDefaults.set(data, forKey: WatchSync.cachedSnapshotKey) + } + WidgetCenter.shared.reloadAllTimelines() + } +} + +extension WatchStore: WCSessionDelegate { + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: (any Error)? + ) { + Task { @MainActor in + WatchStore.shared.isReachable = session.isReachable + WatchStore.shared.refresh() + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + WatchStore.shared.isReachable = reachable + if reachable { WatchStore.shared.refresh() } + } + } + + /// iPhone이 미는 최신 상태 + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let data = applicationContext[WatchSync.snapshotKey] as? Data, + let decoded = WatchSnapshot.decode(data) else { return } + Task { @MainActor in + WatchStore.shared.apply(decoded) + } + } +} + +// MARK: - 색 변환 (워치 타깃 전용) + +extension Color { + /// "#RRGGBB" hex 문자열로 생성 (iOS 쪽 Theme.swift와 동일 규칙) + init(watchHex hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } +} diff --git a/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift b/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift new file mode 100644 index 0000000..a62ddf1 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift @@ -0,0 +1,178 @@ +// +// 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() + } + } +} diff --git a/myApp/HaruDanim/WatchShared/WatchPayload.swift b/myApp/HaruDanim/WatchShared/WatchPayload.swift new file mode 100644 index 0000000..4f63aef --- /dev/null +++ b/myApp/HaruDanim/WatchShared/WatchPayload.swift @@ -0,0 +1,118 @@ +// +// WatchPayload.swift +// Haru_Danim +// +// iPhone ↔ Apple Watch 동기화 페이로드 (CLAUDE.md §9) +// - iOS 앱 타깃 + 워치 앱 타깃 + 워치 위젯 확장에 함께 포함되는 순수 Codable 구조체 +// - iPhone이 스냅숏을 만들어 WCSession으로 보내고, 워치는 UI 표시와 +// 컴플리케이션(App Group defaults에 캐시)에 사용한다 +// + +import Foundation + +nonisolated enum WatchSync { + /// WCSession 메시지/컨텍스트에서 스냅숏 Data가 담기는 키 + static let snapshotKey = "snapshot" + /// 워치 App Group defaults에 최신 스냅숏을 캐시하는 키 (컴플리케이션용) + static let cachedSnapshotKey = "watch.cachedSnapshot" + /// 메시지 명령 키 + static let commandKey = "command" + static let actionIDKey = "actionID" + /// 명령 종류 + static let runCommand = "run" + static let refreshCommand = "refresh" +} + +/// 워치에 보여줄 전체 상태 스냅숏 +nonisolated struct WatchSnapshot: Codable { + var generatedAt: Date = .now + var isPremium: Bool = false + var tags: [WatchTagInfo] = [] + var actions: [WatchActionInfo] = [] + var goals: [WatchGoalInfo] = [] + /// 현재 측정 중 대표 행동 (다이나믹 아일랜드와 동일 기준, CLAUDE.md §9.2-1) + var runningName: String? + var runningSymbol: String? + var runningStartAt: Date? + /// 대표 외에 함께 측정 중인 행동 개수 + var extraRunningCount: Int = 0 + + func encoded() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(_ data: Data) -> WatchSnapshot? { + try? JSONDecoder().decode(WatchSnapshot.self, from: data) + } +} + +nonisolated struct WatchTagInfo: Codable, Identifiable, Hashable { + var id: UUID + var name: String + var colorHex: String +} + +nonisolated struct WatchActionInfo: Codable, Identifiable, Hashable { + var id: UUID + var name: String + var symbolName: String + var colorHex: String + var isCount: Bool + /// 오늘(논리적 하루) 누적값 — 시간형은 초, 횟수형은 회 (스냅숏 시점 기준) + var todayValue: Double + var isRunning: Bool + /// 측정 중일 때 실시간 타이머 기준 시각 (스냅숏 시점 - 오늘 누적초) + var tickingBase: Date? + /// 소속 꼬리표 id들 (비어 있으면 '꼬리표 없음' 그룹) + var tagIDs: [UUID] +} + +nonisolated struct WatchGoalInfo: Codable, Identifiable, Hashable { + var id: UUID + var title: String + var symbolName: String + var colorHex: String + var dayRatio: Double + var weekRatio: Double + var monthRatio: Double + var quests: [WatchQuestInfo] + + func ratio(forSpanRaw raw: String) -> Double { + switch raw { + case "week": return weekRatio + case "month": return monthRatio + default: return dayRatio + } + } +} + +nonisolated struct WatchQuestInfo: Codable, Identifiable, Hashable { + var id: UUID + var name: String + var symbolName: String + var colorHex: String + var dayRatio: Double + var weekRatio: Double + var monthRatio: Double + /// '이하 유지' 다짐 여부 (달성 표현을 다르게, CLAUDE.md §9.2-3) + var isAtMost: Bool + var dayAchieved: Bool + var weekAchieved: Bool + var monthAchieved: Bool + + func ratio(forSpanRaw raw: String) -> Double { + switch raw { + case "week": return weekRatio + case "month": return monthRatio + default: return dayRatio + } + } + + func achieved(forSpanRaw raw: String) -> Bool { + switch raw { + case "week": return weekAchieved + case "month": return monthAchieved + default: return dayAchieved + } + } +}