- 워치 첫 화면 '측정 중' 행이 표시 전용이라 종료하려면 꼬리표/즐겨찾기로 행동을 찾아가야 했던 불편 수정 — 행을 탭하면 대표 측정이 바로 종료됨 - 종료는 행동 목록 탭과 완전히 같은 run 명령 경로 재사용 — 아이폰 쪽 처리(짧은 기록 무시·위젯·라이브 액티비티·동기화)는 기존 그대로라 동기화가 엉킬 여지 없음 - WatchSnapshot에 runningActionID(optional) 추가 — isFavorite와 같은 구버전 캐시 호환 패턴, 구캐시는 이름 매칭 폴백(runningAction 헬퍼) - UI: 오른쪽 노란 정지 아이콘 + "누르면 측정이 종료돼요" 푸터 + 접근성 라벨. 대표를 못 찾는 이례적 경우엔 기존 표시 전용 유지 - 검증: 페어링 심 E2E — 워치 첫 화면에 종료 버튼 렌더 → -autoStopRunning(신설 인자, 탭과 동일 경로) → 아이폰 세션 종료·모음 탭 반영 스크린샷 확인 - 도움말 워치 항목 문장 추가(ko/en/ja), 워치 카탈로그 2키 번역, 전 카탈로그 클린. CLAUDE.md §11·§14 갱신. iOS Debug/Store + 워치(최소 OS 26.2·11.5 둘 다) 빌드 성공 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
138 lines
4.8 KiB
Swift
138 lines
4.8 KiB
Swift
//
|
|
// 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"
|
|
/// 명령이 워치에서 만들어진 시각 (초, since 1970) — 큐 지연 전달 판별용
|
|
static let sentAtKey = "sentAt"
|
|
/// 명령 종류
|
|
static let runCommand = "run"
|
|
static let refreshCommand = "refresh"
|
|
/// 큐 채널(transferUserInfo)로 지연 도착한 실행 명령을 무시하는 한도
|
|
static let queuedRunMaxAge: TimeInterval = 5 * 60
|
|
}
|
|
|
|
/// 워치에 보여줄 전체 상태 스냅숏
|
|
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?
|
|
/// 대표 행동의 id — 워치 첫 화면 '측정 중' 행 탭으로 바로 종료하기 위함.
|
|
/// optional인 이유: 구버전 캐시 스냅숏(키 없음)도 디코딩되게 (isFavorite와 동일 패턴)
|
|
var runningActionID: UUID? = nil
|
|
/// 대표 외에 함께 측정 중인 행동 개수
|
|
var extraRunningCount: Int = 0
|
|
|
|
/// '측정 중' 행 탭으로 종료할 대표 행동 — id로 찾고, 구버전 캐시(키 없음)는 이름 매칭 폴백
|
|
var runningAction: WatchActionInfo? {
|
|
if let runningActionID, let match = actions.first(where: { $0.id == runningActionID }) {
|
|
return match
|
|
}
|
|
guard let runningName else { return nil }
|
|
return actions.first { $0.isRunning && $0.name == runningName }
|
|
}
|
|
|
|
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]
|
|
/// 앱에서 즐겨찾기로 등록한 행동 — 워치 첫 화면의 즐겨찾기 목록에 모인다.
|
|
/// optional인 이유: 구버전 워치에 캐시된 스냅숏(키 없음)도 디코딩되게 하기 위함
|
|
var isFavorite: Bool? = nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|