mycode/myApp/HaruDanim/Shared/HealthMetric.swift
songyc macbook 0a55b45515 feat(1.5-p6): 소킹 중 추가 5건 — 아이폰 일기 탭·일기 건강 카드·타임테이블 수면/운동 실구간·수면 표시 구간(요일별)·맥 필터 안내, 빌드 4
- 아이폰 일기 개방: 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
2026-08-22 21:56:57 +09:00

384 lines
16 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}
}