mycode/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
songyc macbook 081c388f00 fix: 워치-라이브 액티비티 동기화, 앱 이름 띄어쓰기, 차트 라벨 겹침, 위젯 '선택 안 함' 옵션
라이브 액티비티(다이내믹 아일랜드) 워치 연동:
- 워치 실행 명령이 세션 활성화 전이거나 sendMessage 실패 시 유실되던 문제를
  transferUserInfo 큐 폴백으로 보강 (iPhone didReceiveUserInfo 수신 처리 추가)
- 큐로 지연 도착한 실행 명령이 엉뚱한 토글을 일으키지 않도록 5분 유효 시간 부여
- 백그라운드에서 Live Activity 시작이 거부되면 pendingStartRetry로 기억했다가
  앱 포그라운드 복귀(scenePhase active) 시 재시도 + DB 상태 재동기화
- WatchSync 진단용 os_log(notice) 추가, DEBUG -autoRunFirstAction 검증 훅 추가
- 시뮬레이터 검증: 워치 시작 → 아이폰 세션 시작 + 다이내믹 아일랜드 생성,
  워치 종료 → 세션 종료 + 아일랜드 소멸 확인 (큐 폴백 채널은 시뮬레이터
  transferUserInfo 한계로 실기기 확인 필요)

앱 이름 전역 띄어쓰기 ('하루다님' → '하루 다님'):
- pbxproj CFBundleDisplayName 6곳, InfoPlist.xcstrings 3개(ko), 스플래시(ContentView),
  워치 내비게이션 타이틀, 컴플리케이션 문구, 문자열 카탈로그 키 일괄 변경
- 워치 카탈로그 영문 값 HaruDanim → Haru Danim 통일

통계 위젯 X축 라벨 겹침 방지:
- 날짜 축 포인트가 7개를 초과하면(한 달·일별 등) 라벨을 숨기고 눈금만 표시

위젯 설정 '선택 안 함' 옵션:
- Action/Goal/Quest 엔티티에 zero-UUID 센티널 추가, 제안 목록 최상단 노출
- 2·3번째 슬롯 선택을 되돌릴 수 있고, 위젯 조회 단계에서 자연스럽게 걸러짐
- ko/en/ja 번역 추가 (None / 選択しない)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 21:47:42 +09:00

228 lines
10 KiB
Swift

//
// WatchSyncManager.swift
// Haru_Danim
//
// Apple Watch (CLAUDE.md §9.1)
// - DB
// - updateApplicationContext
//
import Foundation
import OSLog
import SwiftData
import WatchConnectivity
import WidgetKit
private let watchSyncLog = Logger(subsystem: "com.yechan.HaruDanim", category: "WatchSync")
@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()
#if DEBUG
watchSyncLog.notice("responds userInfo=\(self.responds(to: NSSelectorFromString("session:didReceiveUserInfo:"))) message=\(self.responds(to: NSSelectorFromString("session:didReceiveMessage:replyHandler:")))")
#endif
}
// 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
}
/// ( )
private var lastComplicationStateKey = ""
private var lastComplicationPushAt = Date.distantPast
/// ( )
func pushSnapshot() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
let snapshot = makeSnapshot()
guard let data = snapshot.encoded() else { return }
// applicationContext:
try? session.updateApplicationContext([WatchSync.snapshotKey: data])
pushForComplicationIfNeeded(session: session, snapshot: snapshot, data: data)
}
/// .
/// 30
private func pushForComplicationIfNeeded(session: WCSession, snapshot: WatchSnapshot, data: Data) {
guard session.isComplicationEnabled else { return }
let stateKey = "\(snapshot.runningName ?? "")|\(snapshot.extraRunningCount)|\(snapshot.isPremium)"
let now = Date.now
guard stateKey != lastComplicationStateKey
|| now.timeIntervalSince(lastComplicationPushAt) > 30 * 60 else { return }
lastComplicationStateKey = stateKey
lastComplicationPushAt = now
session.transferCurrentComplicationUserInfo([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) {
if PremiumGate.isUnlocked(.watchApp) {
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
if let action = actions.first(where: { $0.uuid == id }) {
watchSyncLog.notice("run command: \(action.name, privacy: .public)")
ActionRunner.run(action, context: context)
try? context.save()
LiveActivityManager.sync(context: context)
WidgetCenter.shared.reloadAllTimelines()
} else {
watchSyncLog.error("run command: action not found \(idString, privacy: .public)")
}
} else {
watchSyncLog.error("run command ignored: watchApp premium locked")
}
}
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()
}
}
/// sendMessage ( )
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
let command = userInfo[WatchSync.commandKey] as? String
let actionID = userInfo[WatchSync.actionIDKey] as? String
watchSyncLog.notice("didReceiveUserInfo command=\(command ?? "nil", privacy: .public)")
guard command == WatchSync.runCommand else { return }
//
if let sentAt = userInfo[WatchSync.sentAtKey] as? TimeInterval,
Date.now.timeIntervalSince1970 - sentAt > WatchSync.queuedRunMaxAge {
return
}
Task { @MainActor in
_ = WatchSyncManager.shared.handle(command: command, actionID: actionID)
WatchSyncManager.shared.pushSnapshot()
}
}
}