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
This commit is contained in:
songyc macbook 2026-07-10 09:00:01 +09:00
parent ed8b52792a
commit f9f1898d15
9 changed files with 698 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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)"
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yechan.HaruDanim</string>
</array>
</dict>
</plist>

View File

@ -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()
}
}
}

View File

@ -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
)
}
}

View File

@ -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<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()
}
}
}

View File

@ -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
}
}
}