- 아이폰 일기 개방: AppTab.phoneCases·radialOrder, '그리기' 토글(손가락)·가로 도구줄, 일기 잠금 설정 전 기기 - 건강 카드: DiarySection.health(목록 끝), DiaryHealthCard(지표 선택 공용화·타일 상속·맥 숨김), 인쇄 요약 동승 - 타임테이블 실구간: HealthIntervals 캐시(App Group 400일, CloudKit 금지), injectingHealthBlocks(수면 인디고·운동 주황), 필터 '건강 데이터' 토글+맥 안내 - 수면 표시 구간: SleepWindowPrefs(전체+요일별), 평문 sleep 키=유효 구간 값, 백필 서명 매핑 포함, 새 수면 다짐 기본값 시드 - 실측 수정: 일기 목표 카드 건강 다짐 값 단위, ja 건강 칩 말줄임 - 검증 61/20/27/10 ALL PASS·3종 빌드·카탈로그 0/0·시각 QA 10여 장(26.5/18.5/맥 분기) - 마케팅: 06-bubble 재촬영, 10-week→10-diary 교체, 아이패드 03 재촬영(3언어), whats-new/설명 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
384 lines
16 KiB
Swift
384 lines
16 KiB
Swift
//
|
||
// HealthMetric.swift
|
||
// Haru_Danim
|
||
//
|
||
// 건강 지표 정의 (1.5 — Docs/plan-1.5.md §3)
|
||
// - 모음 탭 타일·건강 다짐이 공유하는 순수 값 타입 (HealthKit 비의존 — 위젯 확장도 컴파일)
|
||
// - 값의 표준 단위: 시간류(운동·수면·마음챙김·운동 종목)=초, 걸음=보, 에너지=kcal,
|
||
// 거리=m, 스탠드=시간, 물=ml. HealthKit 조회·캐시·다짐 목표량·표기가 전부 이 단위를 쓴다
|
||
// - HealthCache: 지표×논리적 하루 값의 App Group 캐시 — 메인 앱만 쓰고(HealthStore),
|
||
// 위젯·시리 확장·QuestProgress는 여기서만 읽는다 (⚠️ 5.1.3 — CloudKit 저장 금지)
|
||
//
|
||
|
||
import Foundation
|
||
|
||
nonisolated enum HealthMetric: String, CaseIterable, Identifiable, Codable {
|
||
case steps, exerciseMinutes, sleep, activeEnergy, distance, standHours, mindfulMinutes, water
|
||
|
||
var id: String { rawValue }
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .steps: return String(localized: "걸음 수")
|
||
case .exerciseMinutes: return String(localized: "운동 시간")
|
||
case .sleep: return String(localized: "수면")
|
||
case .activeEnergy: return String(localized: "활동 에너지")
|
||
case .distance: return String(localized: "이동 거리")
|
||
case .standHours: return String(localized: "일어서기")
|
||
case .mindfulMinutes: return String(localized: "마음챙김")
|
||
case .water: return String(localized: "물 섭취")
|
||
}
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .steps: return "figure.walk"
|
||
case .exerciseMinutes: return "figure.run"
|
||
case .sleep: return "bed.double.fill"
|
||
case .activeEnergy: return "flame.fill"
|
||
case .distance: return "map.fill"
|
||
case .standHours: return "figure.stand"
|
||
case .mindfulMinutes: return "brain.head.profile"
|
||
case .water: return "drop.fill"
|
||
}
|
||
}
|
||
|
||
/// 시간류(초 단위) 지표 여부 — 표기·다짐 목표량 입력 방식 분기용
|
||
var isDuration: Bool {
|
||
switch self {
|
||
case .exerciseMinutes, .sleep, .mindfulMinutes: return true
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
/// 표준 단위 값 → 사용자 표기 (타일·다짐·위젯 공통)
|
||
func valueLabel(_ value: Double) -> String {
|
||
switch self {
|
||
case .steps:
|
||
return String(localized: "\(Int(value).formatted())보")
|
||
case .exerciseMinutes, .sleep, .mindfulMinutes:
|
||
return Format.durationShort(value)
|
||
case .activeEnergy:
|
||
return String(localized: "\(Int(value).formatted())kcal")
|
||
case .distance:
|
||
let km = value / 1000
|
||
let text = km.formatted(.number.precision(.fractionLength(km >= 10 ? 0 : 1)))
|
||
return String(localized: "\(text)km")
|
||
case .standHours:
|
||
return String(localized: "\(Int(value))시간")
|
||
case .water:
|
||
// 표준 단위 ml → L 표기
|
||
let liters = value / 1000
|
||
let text = liters.formatted(.number.precision(.fractionLength(0...2)))
|
||
return String(localized: "\(text)L")
|
||
}
|
||
}
|
||
|
||
/// 타일 기본 표시 세트 (걸음·운동·수면 — 사용자 선택으로 확장)
|
||
static let defaultTileMetrics: [HealthMetric] = [.steps, .exerciseMinutes, .sleep]
|
||
|
||
/// 저장된 선택(순서 포함) 해석 — 비어 있으면 기본 세트
|
||
static func selectedList(raw: String) -> [HealthMetric] {
|
||
let parsed = raw.split(separator: ",").compactMap { HealthMetric(rawValue: String($0)) }
|
||
return parsed.isEmpty ? defaultTileMetrics : parsed
|
||
}
|
||
}
|
||
|
||
// MARK: - 운동 종목 (건강 다짐 전용 — 사용자 핵심 니즈, plan §3.4)
|
||
|
||
/// 종목별 운동 다짐의 큐레이션 목록 (20종). HKWorkoutActivityType 이름은 API가 현지화를
|
||
/// 제공하지 않아 직접 명명한다. 종목 추가 = 케이스 + HealthStore 매핑 + 문자열 3언어.
|
||
nonisolated enum HealthWorkoutKind: String, CaseIterable, Identifiable {
|
||
case running, walking, cycling, swimming, strength, yoga, pilates, hiking
|
||
case soccer, baseball, basketball, tennis, badminton, golf
|
||
case jumpRope, boxing, dance, stretching, hiit, other
|
||
|
||
var id: String { rawValue }
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .running: return String(localized: "달리기")
|
||
case .walking: return String(localized: "걷기 운동")
|
||
case .cycling: return String(localized: "사이클")
|
||
case .swimming: return String(localized: "수영")
|
||
case .strength: return String(localized: "근력 운동")
|
||
case .yoga: return String(localized: "요가")
|
||
case .pilates: return String(localized: "필라테스")
|
||
case .hiking: return String(localized: "등산")
|
||
case .soccer: return String(localized: "축구")
|
||
case .baseball: return String(localized: "야구")
|
||
case .basketball: return String(localized: "농구")
|
||
case .tennis: return String(localized: "테니스")
|
||
case .badminton: return String(localized: "배드민턴")
|
||
case .golf: return String(localized: "골프")
|
||
case .jumpRope: return String(localized: "줄넘기")
|
||
case .boxing: return String(localized: "복싱")
|
||
case .dance: return String(localized: "댄스")
|
||
case .stretching: return String(localized: "스트레칭")
|
||
case .hiit: return String(localized: "고강도 인터벌(HIIT)")
|
||
case .other: return String(localized: "기타 운동")
|
||
}
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .running: return "figure.run"
|
||
case .walking: return "figure.walk"
|
||
case .cycling: return "figure.outdoor.cycle"
|
||
case .swimming: return "figure.pool.swim"
|
||
case .strength: return "dumbbell.fill"
|
||
case .yoga: return "figure.yoga"
|
||
case .pilates: return "figure.pilates"
|
||
case .hiking: return "figure.hiking"
|
||
case .soccer: return "figure.indoor.soccer"
|
||
case .baseball: return "figure.baseball"
|
||
case .basketball: return "figure.basketball"
|
||
case .tennis: return "figure.tennis"
|
||
case .badminton: return "figure.badminton"
|
||
case .golf: return "figure.golf"
|
||
case .jumpRope: return "figure.jumprope"
|
||
case .boxing: return "figure.boxing"
|
||
case .dance: return "figure.dance"
|
||
case .stretching: return "figure.flexibility"
|
||
case .hiit: return "figure.highintensity.intervaltraining"
|
||
case .other: return "figure.mixed.cardio"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 건강 다짐 대상 (Quest.healthMetricRaw 해석)
|
||
|
||
/// 건강 다짐이 겨누는 대상 — 일반 지표 또는 운동 종목.
|
||
/// raw 인코딩: 지표는 HealthMetric raw 그대로, 종목은 "workout.<kind>" (한 필드로 CloudKit 안전)
|
||
nonisolated enum HealthQuestTarget: Equatable {
|
||
case metric(HealthMetric)
|
||
case workout(HealthWorkoutKind)
|
||
|
||
init?(raw: String) {
|
||
guard !raw.isEmpty else { return nil }
|
||
if let metric = HealthMetric(rawValue: raw) {
|
||
self = .metric(metric)
|
||
} else if raw.hasPrefix("workout."),
|
||
let kind = HealthWorkoutKind(rawValue: String(raw.dropFirst("workout.".count))) {
|
||
self = .workout(kind)
|
||
} else {
|
||
// 미래 버전이 만든 미지의 지표 — 구버전과 같은 '대상 없음' 강등 (크래시 없는 안전 경로)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var raw: String {
|
||
switch self {
|
||
case .metric(let metric): return metric.rawValue
|
||
case .workout(let kind): return "workout.\(kind.rawValue)"
|
||
}
|
||
}
|
||
|
||
var name: String {
|
||
switch self {
|
||
case .metric(let metric): return metric.name
|
||
case .workout(let kind): return kind.name
|
||
}
|
||
}
|
||
|
||
var symbolName: String {
|
||
switch self {
|
||
case .metric(let metric): return metric.symbolName
|
||
case .workout(let kind): return kind.symbolName
|
||
}
|
||
}
|
||
|
||
/// 시간류(초 단위) 여부 — 목표량 입력·표기 분기 (운동 종목은 항상 시간)
|
||
var isDuration: Bool {
|
||
switch self {
|
||
case .metric(let metric): return metric.isDuration
|
||
case .workout: return true
|
||
}
|
||
}
|
||
|
||
/// 표준 단위 값 → 사용자 표기
|
||
func valueLabel(_ value: Double) -> String {
|
||
switch self {
|
||
case .metric(let metric): return metric.valueLabel(value)
|
||
case .workout: return Format.durationShort(value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 지표×논리적 하루 값 캐시 (App Group — 위젯·확장의 유일한 값 공급원)
|
||
|
||
nonisolated enum HealthCache {
|
||
static let storageKey = "health.cache.v1"
|
||
/// 연속 계산 상한(§4.2 400일)에 맞춘 지표당 보존 일수
|
||
static let retentionDays = 400
|
||
|
||
/// dayKey(달력일 자정 Date) → "yyyyMMdd" 토큰
|
||
static func dayToken(_ dayKey: Date) -> String {
|
||
let parts = Calendar.current.dateComponents([.year, .month, .day], from: dayKey)
|
||
let y = parts.year ?? 0, m = parts.month ?? 0, d = parts.day ?? 0
|
||
let mm = m < 10 ? "0\(m)" : "\(m)"
|
||
let dd = d < 10 ? "0\(d)" : "\(d)"
|
||
return "\(y)\(mm)\(dd)"
|
||
}
|
||
|
||
private static func load() -> [String: [String: Double]] {
|
||
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
|
||
}
|
||
|
||
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
|
||
load()[metricRaw]?[dayToken(dayKey)]
|
||
}
|
||
|
||
/// 한 날짜의 여러 지표 값을 일괄 기록 (+지표당 보존 일수 초과분 정리)
|
||
static func setValues(_ values: [String: Double], dayKey: Date) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
let token = dayToken(dayKey)
|
||
for (metricRaw, value) in values {
|
||
var days = all[metricRaw] ?? [:]
|
||
days[token] = value
|
||
if days.count > retentionDays {
|
||
let sorted = days.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { days.removeValue(forKey: old) }
|
||
}
|
||
all[metricRaw] = days
|
||
}
|
||
AppGroup.defaults.set(all, forKey: storageKey)
|
||
}
|
||
|
||
/// 여러 날짜 × 여러 지표 일괄 기록 (백필용 — [지표: [토큰: 값]])
|
||
static func merge(_ values: [String: [Date: Double]]) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
for (metricRaw, days) in values {
|
||
var stored = all[metricRaw] ?? [:]
|
||
for (dayKey, value) in days {
|
||
stored[dayToken(dayKey)] = value
|
||
}
|
||
if stored.count > retentionDays {
|
||
let sorted = stored.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
|
||
}
|
||
all[metricRaw] = stored
|
||
}
|
||
AppGroup.defaults.set(all, forKey: storageKey)
|
||
}
|
||
|
||
/// 전체 초기화(데이터 정리)용 — 값은 어차피 건강 앱 소유라 캐시만 비운다
|
||
static func clearAll() {
|
||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||
HealthIntervals.clearAll()
|
||
}
|
||
}
|
||
|
||
// MARK: - 시간 구간 캐시 (1.5 추가분 — 일기 타임테이블의 수면·운동 블록)
|
||
|
||
/// 하루 '합계'만 담는 HealthCache와 달리, 타임테이블 표시용 실제 시간 구간을 담는다.
|
||
/// 키: "sleep" 또는 "workout.<kind>", 값: dayToken → [[시작 epoch, 끝 epoch], ...].
|
||
/// 구간은 그 논리적 하루와 겹치는 부분으로 클리핑해 저장 — 자정을 걸친 잠은 이틀에 나뉜다
|
||
/// (다짐의 '깨어난 날 통귀속'과 다른, 사실 그대로의 표시 규칙 — 의도된 차이).
|
||
/// ⚠️ HealthCache와 동일하게 기기 로컬 defaults 전용 — CloudKit 저장 금지 (5.1.3)
|
||
nonisolated enum HealthIntervals {
|
||
static let storageKey = "health.intervals.v1"
|
||
static let retentionDays = HealthCache.retentionDays
|
||
|
||
static let sleepKey = "sleep"
|
||
static func workoutKey(_ kind: HealthWorkoutKind) -> String { "workout.\(kind.rawValue)" }
|
||
|
||
private static func load() -> [String: [String: [[Double]]]] {
|
||
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:]
|
||
}
|
||
|
||
/// 한 종류·한 날짜의 구간 목록 (시작 오름차순)
|
||
static func intervals(_ key: String, dayKey: Date) -> [(start: Date, end: Date)] {
|
||
let stored = load()[key]?[HealthCache.dayToken(dayKey)] ?? []
|
||
return stored.compactMap { pair in
|
||
guard pair.count == 2, pair[1] > pair[0] else { return nil }
|
||
return (Date(timeIntervalSince1970: pair[0]), Date(timeIntervalSince1970: pair[1]))
|
||
}.sorted { $0.start < $1.start }
|
||
}
|
||
|
||
/// 그날 저장된 운동 종류 키 목록 (표시 순서는 호출부가 정함)
|
||
static func workoutKeys(dayKey: Date) -> [String] {
|
||
let token = HealthCache.dayToken(dayKey)
|
||
return load().keys.filter { $0.hasPrefix("workout.") && !(load()[$0]?[token] ?? []).isEmpty }
|
||
}
|
||
|
||
/// 여러 날짜 × 여러 종류 일괄 기록 (백필·최근 갱신 공용).
|
||
/// values: [종류 키: [dayKey: [[시작 epoch, 끝 epoch]]]] — 빈 배열은 "그날 없음" 기록으로 유효
|
||
static func merge(_ values: [String: [Date: [[Double]]]]) {
|
||
guard !values.isEmpty else { return }
|
||
var all = load()
|
||
for (key, days) in values {
|
||
var stored = all[key] ?? [:]
|
||
for (dayKey, list) in days {
|
||
let token = HealthCache.dayToken(dayKey)
|
||
if list.isEmpty {
|
||
stored.removeValue(forKey: token)
|
||
} else {
|
||
stored[token] = list
|
||
}
|
||
}
|
||
if stored.count > retentionDays {
|
||
let sorted = stored.keys.sorted(by: >)
|
||
for old in sorted.dropFirst(retentionDays) { stored.removeValue(forKey: old) }
|
||
}
|
||
all[key] = stored
|
||
}
|
||
AppGroup.defaults.set(all, forKey: storageKey)
|
||
}
|
||
|
||
static func clearAll() {
|
||
AppGroup.defaults.removeObject(forKey: storageKey)
|
||
}
|
||
}
|
||
|
||
// MARK: - 기기 가용성 (§8ⓒⓕ — 데이터 없는 기기의 모수 제외·판정 보류)
|
||
|
||
nonisolated enum HealthSupport {
|
||
static let deviceAvailableKey = "health.deviceAvailable"
|
||
|
||
/// 이 기기에서 건강 데이터를 쓸 수 있는지 — 메인 앱이 시작 때 기록(맥=false).
|
||
/// 기록이 아직 없으면(위젯이 앱보다 먼저 도는 최초 상태) 낙관적으로 true.
|
||
static var deviceAvailable: Bool {
|
||
guard AppGroup.defaults.object(forKey: deviceAvailableKey) != nil else { return true }
|
||
return AppGroup.defaults.bool(forKey: deviceAvailableKey)
|
||
}
|
||
}
|
||
|
||
// MARK: - 건강 다짐 값 공급 (QuestProgress가 읽는 유일한 경로 — 캐시 합산)
|
||
|
||
nonisolated enum HealthQuestValues {
|
||
#if DEBUG
|
||
/// 자가 검증용 오버라이드(-progressSelfTest H 시리즈) — 실 캐시·기기 설정 무영향
|
||
nonisolated(unsafe) static var testOverride: [String: [String: Double]]?
|
||
#endif
|
||
|
||
/// 다짐의 캐시 키 — 수면은 다짐별 수면 구간이 값 자체를 바꾸므로 구간을 키에 포함
|
||
static func cacheKey(target: HealthQuestTarget, quest: Quest) -> String {
|
||
if case .metric(.sleep) = target {
|
||
return "sleep@\(quest.sleepWindowStartMinutes)-\(quest.sleepWindowEndMinutes)"
|
||
}
|
||
return target.raw
|
||
}
|
||
|
||
static func dayValue(target: HealthQuestTarget, quest: Quest, dayKey: Date) -> Double? {
|
||
let key = cacheKey(target: target, quest: quest)
|
||
#if DEBUG
|
||
if let override = testOverride {
|
||
return override[key]?[HealthCache.dayToken(dayKey)]
|
||
}
|
||
#endif
|
||
return HealthCache.value(key, dayKey: dayKey)
|
||
}
|
||
|
||
/// range에 걸친 논리적 하루들의 캐시 값 합.
|
||
/// 건강 다짐은 하루 단위 전용이라 호출부의 range는 논리적 하루(또는 그 판정 클리핑)다 —
|
||
/// 오늘의 부분 클리핑은 "오늘 캐시 = 마지막 갱신 시점까지의 값"이라 자연히 now 근사가 된다.
|
||
static func sum(target: HealthQuestTarget, quest: Quest, in range: Range<Date>, math: DayMath) -> Double {
|
||
math.dayKeys(in: range).reduce(0) { partial, key in
|
||
partial + (dayValue(target: target, quest: quest, dayKey: key) ?? 0)
|
||
}
|
||
}
|
||
}
|