songyc macbook 22ed06f2a1 feat(1.4-b2): 도움말 재구성·워치 햅틱 상향·빌드 번호 2 — TestFlight 재업로드 준비
- 도움말 뭉친 주제 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
2026-08-12 00:11:36 +09:00

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