songyc macbook 232e8c504d fix(watch): list every goal & quest in complication recommendations
- 페이스 편집기에 3번째 목표부터(다짐은 첫 목표의 2개까지만) 안 뜨던 원인 = recommendations()의 prefix(2) 하드캡 — 워치는 컴플리케이션 파라미터 편집 UI가 없어 추천 목록이 곧 선택지의 전부라, 캡에 걸린 목표·다짐은 페이스에 추가할 방법 자체가 없었음 (보고된 13개 = 현재현황 1 + 목표 2×3기간 + 다짐 2×3기간과 정확히 일치)
- 목표·다짐 달성률 둘 다 상한 제거: 진행 중 목표 전부 × 3기간, 다짐 전부 × 3기간 나열
- 워치 앱이 스냅숏을 적용할 때 invalidateConfigurationRecommendations() 호출 추가 — 이후 아이폰에서 목표를 새로 만들어도 시스템 임의 재조회를 기다리지 않고 목록에 자동 반영
- CLAUDE.md §11에 "추천 목록 = 선택지 전부, 상한 금지" 규칙 명시
- 검증: 워치·Debug·Store 3스킴 빌드 성공, 워치 심 -complicationPreview 렌더 정상 (추천 목록 자체는 페이스 편집 UI라 헤드리스 확인 불가 — 실기기 확인 필요)

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

186 lines
7.5 KiB
Swift

//
// WatchStore.swift
// Haru_DanimWatch Watch App
//
// iPhone WCSession (CLAUDE.md §9.1)
// - iPhone , iPhone
// - App Group defaults
//
import Foundation
import Observation
import OSLog
import SwiftUI
import WatchConnectivity
import WidgetKit
private let watchLog = Logger(subsystem: "com.yechan.HaruDanim.watchkitapp", category: "WatchStore")
@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,
WatchSync.sentAtKey: Date.now.timeIntervalSince1970,
])
}
private func send(_ message: [String: Any]) {
let session = WCSession.default
guard session.activationState == .activated else {
// ( )
if message[WatchSync.commandKey] as? String == WatchSync.runCommand {
watchLog.notice("send before activation → transferUserInfo queue")
session.transferUserInfo(message)
}
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
// (transferUserInfo)
// iPhone didReceiveUserInfo
if message[WatchSync.commandKey] as? String == WatchSync.runCommand {
watchLog.notice("sendMessage failed → transferUserInfo queue")
session.transferUserInfo(message)
} else {
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()
// ·
// ( )
WidgetCenter.shared.invalidateConfigurationRecommendations()
#if DEBUG
autoRunIfNeeded()
#endif
}
#if DEBUG
/// -autoRunFirstAction YES:
/// ( iPhone Live Activity CLI )
private var didAutoRun = false
private func autoRunIfNeeded() {
// transferUserInfo
// sendMessage (reachable)
watchLog.notice("autoRun check: done=\(self.didAutoRun) flag=\(UserDefaults.standard.bool(forKey: "autoRunFirstAction")) actions=\(self.snapshot?.actions.count ?? -1) reachable=\(WCSession.default.isReachable)")
guard !didAutoRun,
UserDefaults.standard.bool(forKey: "autoRunFirstAction"),
WCSession.default.activationState == .activated,
WCSession.default.isReachable,
let action = snapshot?.actions.first(where: { !$0.isCount }) else { return }
didAutoRun = true
watchLog.notice("autoRun firing: \(action.name, privacy: .public)")
run(action)
}
#endif
}
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)
}
}
/// iPhone .
/// + (apply)
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
guard let data = userInfo[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
)
}
}