- 가끔 횟수 +2 실측: 응답 유실 시 큐 재전송이 같은 명령을 두 번 실행 — run 명령에 탭당 고유 commandID 동봉, iPhone이 최근 64개 기억해 중복 무시 - 컴플리케이션 미니 화면: 탭 → 체크 오버레이+눌림 축소(기존 진동과 함께) 0.7초 → 자동 메인 복귀, 접수 중 재탭 무시 (놔둔 채 오탭 방지 — 사용자 요구) - QA: -watchDetailAutoTap 신설, 시드 모드 미활성 세션 전송 가드 - 검증: 4종 빌드 그린, 워치 26.2·11.5 시각 QA, 페어링 E2E 실행 1회·중복 0 - MARKETING_VERSION 1.5.1(빌드 1), whats-new-1.5.1 3언어 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
166 lines
6.6 KiB
Swift
166 lines
6.6 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"
|
||
/// 탭 1회마다 새로 만드는 명령 고유 ID (1.5.1) — sendMessage가 실제로는 전달됐는데
|
||
/// 응답만 유실되면 워치가 같은 명령을 큐 채널로 재전송한다. iPhone이 이 ID로
|
||
/// 중복을 걸러내지 않으면 탭 한 번에 두 번 실행된다(횟수 +2 실측 보고의 원인)
|
||
static let commandIDKey = "commandID"
|
||
/// 명령 종류
|
||
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
|
||
/// '행동 기록' 컴플리케이션 표시 기간 raw (1.5) — 아이폰 행동 편집에서 옵트인한 행동만 채워짐.
|
||
/// nil이면 컴플리케이션 비대상. optional인 이유: 구버전 캐시 호환 (isFavorite와 동일 패턴)
|
||
var complicationPeriodRaw: String? = nil
|
||
/// 옵트인 행동의 설정 기간 누적값 — 시간형은 초, 횟수형은 회 (스냅숏 시점 기준)
|
||
var periodValue: Double? = nil
|
||
/// 시간형이 측정 중일 때 기간 누적 실시간 타이머 기준 시각 (스냅숏 시점 - 기간 누적초)
|
||
var periodTickingBase: Date? = nil
|
||
}
|
||
|
||
/// '행동 기록' 컴플리케이션의 표시 기간 (1.5 — 아이폰 행동 편집에서 지정, 기기 로컬 LocalPrefs).
|
||
/// ⚠️ 다짐 진행률의 StatSpan과 별개 타입 — StatSpan에는 케이스를 추가하지 않는다 (§6.6).
|
||
/// 롤링(last7/last30)은 통계 '오늘 기준' 보기(1.4)와 같은 개념: 오늘로 끝나는 최근 N일 창.
|
||
nonisolated enum WatchActionPeriod: String, Codable, CaseIterable {
|
||
case day, week, month, last7, last30
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .day: return String(localized: "하루")
|
||
case .week: return String(localized: "이번 주")
|
||
case .month: return String(localized: "이번 달")
|
||
case .last7: return String(localized: "지난 7일")
|
||
case .last30: return String(localized: "지난 30일")
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|