feat(watch): 애플워치 앱 + WCSession 실시간 연동 (CLAUDE.md §9.1)
- WatchShared/WatchPayload: iOS·워치·워치위젯 3개 타깃이 공유하는 Codable 스냅숏 (꼬리표/행동/목표·다짐 진행률/측정 중 대표 행동 + 추가 개수/프리미엄) - iOS WatchSyncManager: 워치의 실행·새로 고침 명령 처리(ActionRunner 공용) 후 Live Activity·홈 위젯 갱신, 데이터 변경 시 updateApplicationContext로 푸시 - 워치 앱: 꼬리표 목록 → 행동 목록 → 탭으로 실행(시간형 토글/횟수형 +1), 측정 중 실시간 타이머, 대표 행동 표시는 다이나믹 아일랜드 설정과 동일 기준 - 미결제 시 프리미엄 잠금, iPhone 연결 불가 안내 - 수신 스냅숏을 워치 App Group에 캐시 (컴플리케이션용) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
parent
ed8b52792a
commit
f9f1898d15
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
213
myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift
Normal file
213
myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// Haru_DanimWatch Watch App
|
||||||
|
//
|
||||||
|
// 워치 앱 본체 (CLAUDE.md §9.1)
|
||||||
|
// 꼬리표 목록 → 행동 목록 → 탭으로 실행 (시간형 토글 / 횟수형 +1)
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@State private var store = WatchStore.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
#if DEBUG
|
||||||
|
if UserDefaults.standard.bool(forKey: "complicationPreview") {
|
||||||
|
ComplicationPreviewScreen()
|
||||||
|
} else {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
mainContent
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.navigationTitle("하루다님")
|
||||||
|
}
|
||||||
|
.onAppear { store.activate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainContent: some View {
|
||||||
|
if let snapshot = store.snapshot {
|
||||||
|
if snapshot.isPremium {
|
||||||
|
tagList(snapshot)
|
||||||
|
} else {
|
||||||
|
lockedView
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("iPhone과 연결 중...")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lockedView: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: "crown.fill")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
Text("프리미엄 기능")
|
||||||
|
.font(.headline)
|
||||||
|
Text("iPhone 앱의 설정 → 프리미엄에서 잠금 해제해 주세요")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: 꼬리표 목록
|
||||||
|
|
||||||
|
private func tagList(_ snapshot: WatchSnapshot) -> some View {
|
||||||
|
List {
|
||||||
|
if let name = snapshot.runningName {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: snapshot.runningSymbol ?? "record.circle")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(name)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let start = snapshot.runningStartAt {
|
||||||
|
Text(start, style: .timer)
|
||||||
|
.font(.footnote.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if snapshot.extraRunningCount > 0 {
|
||||||
|
Text("+\(snapshot.extraRunningCount)")
|
||||||
|
.font(.caption2.weight(.bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("측정 중")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(snapshot.tags) { tag in
|
||||||
|
let actions = snapshot.actions.filter { $0.tagIDs.contains(tag.id) }
|
||||||
|
if !actions.isEmpty {
|
||||||
|
NavigationLink {
|
||||||
|
WatchActionListView(title: tag.name, actions: actions)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(watchHex: tag.colorHex))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text(tag.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text("\(actions.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let untagged = snapshot.actions.filter { $0.tagIDs.isEmpty }
|
||||||
|
if !untagged.isEmpty {
|
||||||
|
NavigationLink {
|
||||||
|
WatchActionListView(title: "꼬리표 없음", actions: untagged)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(.secondary, lineWidth: 1)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text("꼬리표 없음")
|
||||||
|
Spacer()
|
||||||
|
Text("\(untagged.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if store.lastSendFailed {
|
||||||
|
Text("iPhone에 연결할 수 없어요. 근처에 있는지 확인해 주세요.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 행동 목록
|
||||||
|
|
||||||
|
struct WatchActionListView: View {
|
||||||
|
@State private var store = WatchStore.shared
|
||||||
|
let title: String
|
||||||
|
let actions: [WatchActionInfo]
|
||||||
|
|
||||||
|
/// 최신 스냅숏의 상태로 갱신된 행동 목록 (실행 후 반영)
|
||||||
|
private var currentActions: [WatchActionInfo] {
|
||||||
|
guard let snapshot = store.snapshot else { return actions }
|
||||||
|
return actions.compactMap { old in
|
||||||
|
snapshot.actions.first { $0.id == old.id } ?? old
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(currentActions) { action in
|
||||||
|
Button {
|
||||||
|
store.run(action)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: action.symbolName)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.background(
|
||||||
|
Color(watchHex: action.colorHex),
|
||||||
|
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(action.name)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
valueText(action)
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if action.isRunning {
|
||||||
|
Image(systemName: "record.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func valueText(_ action: WatchActionInfo) -> some View {
|
||||||
|
if action.isCount {
|
||||||
|
Text("오늘 \(Int(action.todayValue))회")
|
||||||
|
} else if let base = action.tickingBase {
|
||||||
|
Text(base, style: .timer)
|
||||||
|
} else {
|
||||||
|
Text("오늘 \(WatchFormat.duration(action.todayValue))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 워치 전용 간단 포매터
|
||||||
|
enum WatchFormat {
|
||||||
|
static func duration(_ seconds: Double) -> String {
|
||||||
|
let total = Int(seconds.rounded())
|
||||||
|
let h = total / 3600
|
||||||
|
let m = (total % 3600) / 60
|
||||||
|
if h > 0 { return m > 0 ? "\(h)시간 \(m)분" : "\(h)시간" }
|
||||||
|
if m > 0 { return "\(m)분" }
|
||||||
|
return "\(total)초"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.yechan.HaruDanim</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// Haru_DanimWatchApp.swift
|
||||||
|
// Haru_DanimWatch Watch App
|
||||||
|
//
|
||||||
|
// Created by 송예찬 on 7/10/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct Haru_DanimWatch_Watch_AppApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift
Normal file
132
myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
//
|
||||||
|
// WatchStore.swift
|
||||||
|
// Haru_DanimWatch Watch App
|
||||||
|
//
|
||||||
|
// iPhone과의 WCSession 연동 상태 저장소 (CLAUDE.md §9.1)
|
||||||
|
// - iPhone이 보내는 스냅숏을 보관하고, 탭 명령을 iPhone으로 보낸다
|
||||||
|
// - 최신 스냅숏은 App Group defaults에 캐시해 컴플리케이션이 읽게 한다
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
import WatchConnectivity
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@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,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ message: [String: Any]) {
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else { 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
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
178
myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
Normal file
178
myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
//
|
||||||
|
// WatchSyncManager.swift
|
||||||
|
// Haru_Danim
|
||||||
|
//
|
||||||
|
// Apple Watch 실시간 연동 (CLAUDE.md §9.1)
|
||||||
|
// - 워치의 실행 명령을 받아 DB에 반영하고 최신 스냅숏을 돌려준다
|
||||||
|
// - 데이터가 바뀔 때마다 updateApplicationContext로 최신 상태를 밀어 넣는다
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
import WatchConnectivity
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최신 스냅숏을 워치로 전송 (데이터 변경 시마다 호출)
|
||||||
|
func pushSnapshot() {
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
|
||||||
|
guard let data = makeSnapshot().encoded() else { return }
|
||||||
|
try? session.updateApplicationContext([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),
|
||||||
|
PremiumGate.isUnlocked(.watchApp) {
|
||||||
|
let actions = (try? context.fetch(FetchDescriptor<Action>())) ?? []
|
||||||
|
if let action = actions.first(where: { $0.uuid == id }) {
|
||||||
|
ActionRunner.run(action, context: context)
|
||||||
|
try? context.save()
|
||||||
|
LiveActivityManager.sync(context: context)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
myApp/HaruDanim/WatchShared/WatchPayload.swift
Normal file
118
myApp/HaruDanim/WatchShared/WatchPayload.swift
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// WatchPayload.swift
|
||||||
|
// Haru_Danim
|
||||||
|
//
|
||||||
|
// iPhone ↔ Apple Watch 동기화 페이로드 (CLAUDE.md §9)
|
||||||
|
// - iOS 앱 타깃 + 워치 앱 타깃 + 워치 위젯 확장에 함께 포함되는 순수 Codable 구조체
|
||||||
|
// - iPhone이 스냅숏을 만들어 WCSession으로 보내고, 워치는 UI 표시와
|
||||||
|
// 컴플리케이션(App Group defaults에 캐시)에 사용한다
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
nonisolated enum WatchSync {
|
||||||
|
/// WCSession 메시지/컨텍스트에서 스냅숏 Data가 담기는 키
|
||||||
|
static let snapshotKey = "snapshot"
|
||||||
|
/// 워치 App Group defaults에 최신 스냅숏을 캐시하는 키 (컴플리케이션용)
|
||||||
|
static let cachedSnapshotKey = "watch.cachedSnapshot"
|
||||||
|
/// 메시지 명령 키
|
||||||
|
static let commandKey = "command"
|
||||||
|
static let actionIDKey = "actionID"
|
||||||
|
/// 명령 종류
|
||||||
|
static let runCommand = "run"
|
||||||
|
static let refreshCommand = "refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 워치에 보여줄 전체 상태 스냅숏
|
||||||
|
nonisolated struct WatchSnapshot: Codable {
|
||||||
|
var generatedAt: Date = .now
|
||||||
|
var isPremium: Bool = false
|
||||||
|
var tags: [WatchTagInfo] = []
|
||||||
|
var actions: [WatchActionInfo] = []
|
||||||
|
var goals: [WatchGoalInfo] = []
|
||||||
|
/// 현재 측정 중 대표 행동 (다이나믹 아일랜드와 동일 기준, CLAUDE.md §9.2-1)
|
||||||
|
var runningName: String?
|
||||||
|
var runningSymbol: String?
|
||||||
|
var runningStartAt: Date?
|
||||||
|
/// 대표 외에 함께 측정 중인 행동 개수
|
||||||
|
var extraRunningCount: Int = 0
|
||||||
|
|
||||||
|
func encoded() -> Data? {
|
||||||
|
try? JSONEncoder().encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) -> WatchSnapshot? {
|
||||||
|
try? JSONDecoder().decode(WatchSnapshot.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct WatchTagInfo: Codable, Identifiable, Hashable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var colorHex: String
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct WatchActionInfo: Codable, Identifiable, Hashable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var symbolName: String
|
||||||
|
var colorHex: String
|
||||||
|
var isCount: Bool
|
||||||
|
/// 오늘(논리적 하루) 누적값 — 시간형은 초, 횟수형은 회 (스냅숏 시점 기준)
|
||||||
|
var todayValue: Double
|
||||||
|
var isRunning: Bool
|
||||||
|
/// 측정 중일 때 실시간 타이머 기준 시각 (스냅숏 시점 - 오늘 누적초)
|
||||||
|
var tickingBase: Date?
|
||||||
|
/// 소속 꼬리표 id들 (비어 있으면 '꼬리표 없음' 그룹)
|
||||||
|
var tagIDs: [UUID]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct WatchGoalInfo: Codable, Identifiable, Hashable {
|
||||||
|
var id: UUID
|
||||||
|
var title: String
|
||||||
|
var symbolName: String
|
||||||
|
var colorHex: String
|
||||||
|
var dayRatio: Double
|
||||||
|
var weekRatio: Double
|
||||||
|
var monthRatio: Double
|
||||||
|
var quests: [WatchQuestInfo]
|
||||||
|
|
||||||
|
func ratio(forSpanRaw raw: String) -> Double {
|
||||||
|
switch raw {
|
||||||
|
case "week": return weekRatio
|
||||||
|
case "month": return monthRatio
|
||||||
|
default: return dayRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated struct WatchQuestInfo: Codable, Identifiable, Hashable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var symbolName: String
|
||||||
|
var colorHex: String
|
||||||
|
var dayRatio: Double
|
||||||
|
var weekRatio: Double
|
||||||
|
var monthRatio: Double
|
||||||
|
/// '이하 유지' 다짐 여부 (달성 표현을 다르게, CLAUDE.md §9.2-3)
|
||||||
|
var isAtMost: Bool
|
||||||
|
var dayAchieved: Bool
|
||||||
|
var weekAchieved: Bool
|
||||||
|
var monthAchieved: Bool
|
||||||
|
|
||||||
|
func ratio(forSpanRaw raw: String) -> Double {
|
||||||
|
switch raw {
|
||||||
|
case "week": return weekRatio
|
||||||
|
case "month": return monthRatio
|
||||||
|
default: return dayRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func achieved(forSpanRaw raw: String) -> Bool {
|
||||||
|
switch raw {
|
||||||
|
case "week": return weekAchieved
|
||||||
|
case "month": return monthAchieved
|
||||||
|
default: return dayAchieved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user