- 페이스 편집기에 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
186 lines
7.5 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|