- 도움말 뭉친 주제 3곳 분리: '데이터 정리·초기화'(CSV 주제에서)·'지난 7일·30일로 보기'(비교 주제에서)·'사진·도형·텍스트 꾸미기'(펜슬 주제에서) 독립 — 비교·CSV 주제는 단일 기능 설명으로 복귀 - 도움말 이미지 18장 전수 실사: 코드 참조 6종×ko/en/ja 완전 매칭·고아/누락 0, ko·en 실렌더로 언어별 이미지 로딩 확인 - 신규·재구성 문구 8건 en/ja 완역(기존 번역 재조합으로 품질 유지), stale 3키 청소 — 전 카탈로그 missing/stale 0 - 워치 행동 실행 햅틱 .click→.success(가장 강한 축) — 실사용 '너무 약함' 보고 반영 - CURRENT_PROJECT_VERSION 1→2 (TestFlight 1.4(2)) - 동기화 삭제 전파 재확인: 컨텍스트 경유 개별 삭제라 CloudKit로 전 기기 전파(의도된 설계·경고 문구 존재) - 검증: Debug/Store/워치 3빌드 성공, 자가 검증 49+20+18 ALL PASS 재확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
206 lines
8.5 KiB
Swift
206 lines
8.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 WatchKit
|
|
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) {
|
|
// 손목에서는 화면을 안 보고 탭하는 경우가 많다 — 접수 즉시 햅틱으로 확인.
|
|
// .click은 실사용에서 너무 약하다는 보고(1.4)로 가장 강한 축인 .success로 상향
|
|
WKInterfaceDevice.current().play(.success)
|
|
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()
|
|
autoStopIfNeeded()
|
|
#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)
|
|
}
|
|
|
|
/// -autoStopRunning YES: 스냅숏 수신 후 '측정 중' 행 탭과 동일한 경로로 대표 측정을 종료
|
|
/// (첫 화면 종료 버튼의 CLI 검증용 — 실행 조건은 autoRun과 동일)
|
|
private var didAutoStop = false
|
|
private func autoStopIfNeeded() {
|
|
guard !didAutoStop,
|
|
UserDefaults.standard.bool(forKey: "autoStopRunning"),
|
|
WCSession.default.activationState == .activated,
|
|
WCSession.default.isReachable,
|
|
let snapshot,
|
|
let action = snapshot.runningAction else { return }
|
|
didAutoStop = true
|
|
watchLog.notice("autoStop 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
|
|
)
|
|
}
|
|
}
|