- fix: 더보기 탭 일기 달력 날짜 탭 무반응 — morePath [AppTab]→NavigationPath(Date 푸시 가능), 회귀 인자 -morePushDiaryToday(착지 실측) - fix: 수면 블록이 수면 단계별로 칸칸이 갈라짐 — 10분 이하 깸 병합(sleepDisplayMergeGap, 표시 전용·값 무영향) - feat: 기록 탭 타임테이블(하루·주간)에 수면·운동 블록 — 단일 공급 지점·숨김 토글 공유, 내보내기 injectingHealthBlocks(forDayKeys:)+trim 확장 - feat: 타임테이블 표시 색 설정(설정→건강 데이터 — 기본 인디고·주황, 전 타임테이블·내보내기 공통), -healthColorPreview - QA: 주간 6일 블록 렌더·커스텀 색 픽셀 검증(#FF2D55→(255,221,228))·색 설정 화면·Date 푸시 착지, l10n 0/0, Debug/Store 그린 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
720 lines
32 KiB
Swift
720 lines
32 KiB
Swift
//
|
||
// HealthSection.swift
|
||
// Haru_Danim
|
||
//
|
||
// 모음 탭 건강 데이터 섹션 (1.5 — Docs/plan-1.5.md §3.3, 무료)
|
||
// - 가로 스크롤 컴팩트 타일 줄 + 접기 (즐겨찾기/나머지 섹션과 같은 접힘 문법)
|
||
// - 지표 선택 시트(체크+순서)·안내 시트(건강 데이터 특성 — 사용자 요구 2026-08-22)
|
||
// - 권한 요청은 명시적 탭에서만 (연결 CTA 타일)
|
||
// - MainView의 금지구역(actionGrid·레이아웃)과 완전히 분리된 독립 뷰 (§10 R4)
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
struct HealthSectionView: View {
|
||
@AppStorage(LocalPrefsKeys.healthCollapsed, store: AppGroup.defaults)
|
||
private var collapsed = false
|
||
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults)
|
||
private var metricsRaw = ""
|
||
|
||
@State private var store = HealthDataStore.shared
|
||
@State private var showingMetricPicker = false
|
||
@State private var showingGuide = false
|
||
|
||
private var metrics: [HealthMetric] {
|
||
HealthMetric.selectedList(raw: metricsRaw)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
header
|
||
if !collapsed {
|
||
content
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingMetricPicker) {
|
||
HealthMetricPickerSheet(metricsRaw: $metricsRaw)
|
||
}
|
||
.sheet(isPresented: $showingGuide) {
|
||
HealthGuideSheet()
|
||
}
|
||
.task { await store.refreshToday() }
|
||
#if DEBUG
|
||
// 검증용: -healthShowGuide YES → 안내 시트, -healthShowMetricPicker YES → 지표 선택 시트
|
||
.onAppear {
|
||
if UserDefaults.standard.bool(forKey: "healthShowGuide") { showingGuide = true }
|
||
if UserDefaults.standard.bool(forKey: "healthShowMetricPicker") { showingMetricPicker = true }
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 6) {
|
||
Button {
|
||
withAnimation(.smooth(duration: 0.25)) { collapsed.toggle() }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "heart.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.pink)
|
||
Text("건강 데이터")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(AppTheme.green)
|
||
Image(systemName: "chevron.down")
|
||
.font(.caption.weight(.bold))
|
||
.foregroundStyle(.secondary)
|
||
.rotationEffect(.degrees(collapsed ? -90 : 0))
|
||
}
|
||
.padding(.leading, 2)
|
||
.contentShape(.rect)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(collapsed ? Text("건강 데이터 펼치기") : Text("건강 데이터 접기"))
|
||
Spacer(minLength: 0)
|
||
if !collapsed {
|
||
Button {
|
||
showingGuide = true
|
||
} label: {
|
||
Image(systemName: "info.circle")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text("건강 데이터 안내"))
|
||
Button {
|
||
showingMetricPicker = true
|
||
} label: {
|
||
Image(systemName: "slider.horizontal.3")
|
||
.font(.subheadline)
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text("표시할 지표 선택"))
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
if !store.hasRequestedAuth {
|
||
connectTile
|
||
} else {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(metrics) { metric in
|
||
tile(metric)
|
||
}
|
||
}
|
||
.padding(.vertical, 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 아직 권한을 요청한 적 없는 상태 — 명시적 탭으로만 연결 (자동 권한 프롬프트 금지)
|
||
private var connectTile: some View {
|
||
Button {
|
||
Task { await store.requestAuthorization() }
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "heart.text.square.fill")
|
||
.font(.title3)
|
||
.foregroundStyle(.pink)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("애플 건강 연결하기")
|
||
.font(.subheadline.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
Text("걸음·운동·수면 같은 오늘 데이터를 여기서 볼 수 있어요")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer(minLength: 0)
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.padding(12)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.contentShape(.rect)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
private func tile(_ metric: HealthMetric) -> some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Image(safeSymbol: metric.symbolName)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.pink)
|
||
Text(metric.valueLabel(store.todayValues[metric] ?? 0))
|
||
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
Text(metric.name)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.padding(10)
|
||
.frame(minWidth: 86, alignment: .leading)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(Text(verbatim: "\(metric.name) \(metric.valueLabel(store.todayValues[metric] ?? 0))"))
|
||
}
|
||
}
|
||
|
||
// MARK: - 지표 선택 시트 (체크 + 드래그 순서 — 모음 탭 타일·일기 건강 카드 공용)
|
||
|
||
struct HealthMetricPickerSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
/// 저장 대상 raw 문자열 (모음 탭=AppGroup healthMetrics, 일기 카드=standard diary.healthMetrics)
|
||
@Binding var metricsRaw: String
|
||
/// metricsRaw가 비어 있을 때의 초기 선택 폴백 (일기 카드가 타일 선택을 물려받는 용도)
|
||
var fallbackRaw: String = ""
|
||
|
||
/// 편집 목록: 선택된 지표(순서 유지) + 나머지 지표
|
||
@State private var selected: [HealthMetric] = []
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
Section {
|
||
ForEach(selected) { metric in
|
||
row(metric, isOn: true)
|
||
}
|
||
.onMove { source, destination in
|
||
selected.move(fromOffsets: source, toOffset: destination)
|
||
}
|
||
} header: {
|
||
Text("표시할 지표 (드래그로 순서 변경)")
|
||
} footer: {
|
||
if selected.isEmpty {
|
||
Text("최소 1개는 선택해야 저장돼요.")
|
||
}
|
||
}
|
||
Section("표시 안 함") {
|
||
ForEach(HealthMetric.allCases.filter { !selected.contains($0) }) { metric in
|
||
row(metric, isOn: false)
|
||
}
|
||
}
|
||
}
|
||
.environment(\.editMode, .constant(.active))
|
||
.navigationTitle("건강 지표")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("완료") {
|
||
if !selected.isEmpty {
|
||
metricsRaw = selected.map(\.rawValue).joined(separator: ",")
|
||
}
|
||
dismiss()
|
||
}
|
||
.disabled(selected.isEmpty)
|
||
}
|
||
}
|
||
.onAppear {
|
||
selected = HealthMetric.selectedList(raw: metricsRaw.isEmpty ? fallbackRaw : metricsRaw)
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
|
||
private func row(_ metric: HealthMetric, isOn: Bool) -> some View {
|
||
Button {
|
||
withAnimation(.smooth(duration: 0.2)) {
|
||
if isOn {
|
||
selected.removeAll { $0 == metric }
|
||
} else {
|
||
selected.append(metric)
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 10) {
|
||
Image(safeSymbol: metric.symbolName)
|
||
.font(.subheadline)
|
||
.foregroundStyle(.pink)
|
||
.frame(width: 22)
|
||
Text(metric.name)
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
Image(systemName: isOn ? "checkmark.circle.fill" : "circle")
|
||
.foregroundStyle(isOn ? AppTheme.green : .secondary)
|
||
}
|
||
.contentShape(.rect)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - 모음 탭 3섹션 순서 설정 (설정 탭에서 진입 — plan §3.3)
|
||
|
||
struct MainSectionOrderView: View {
|
||
@AppStorage(LocalPrefsKeys.mainSectionOrder, store: AppGroup.defaults)
|
||
private var orderRaw = ""
|
||
|
||
@State private var order: [MainSectionKind] = []
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
ForEach(order) { kind in
|
||
HStack(spacing: 10) {
|
||
Image(systemName: symbol(kind))
|
||
.font(.subheadline)
|
||
.foregroundStyle(kind == .health ? .pink : AppTheme.green)
|
||
.frame(width: 22)
|
||
Text(kind.label)
|
||
}
|
||
}
|
||
.onMove { source, destination in
|
||
order.move(fromOffsets: source, toOffset: destination)
|
||
MainSectionKind.saveOrder(order)
|
||
orderRaw = order.map(\.rawValue).joined(separator: ",")
|
||
}
|
||
} footer: {
|
||
Text("드래그로 순서를 바꾸면 모음 탭에 바로 적용돼요. 목표 진행 현황 카드와 '현재 진행 중' 영역은 항상 위에 고정돼요.")
|
||
}
|
||
}
|
||
.environment(\.editMode, .constant(.active))
|
||
.navigationTitle("섹션 표시 순서")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.onAppear {
|
||
order = MainSectionKind.orderedList(raw: orderRaw)
|
||
}
|
||
}
|
||
|
||
private func symbol(_ kind: MainSectionKind) -> String {
|
||
switch kind {
|
||
case .favorites: return "star.fill"
|
||
case .others: return "square.grid.2x2"
|
||
case .health: return "heart.fill"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 건강 데이터 안내 시트 (사용자 요구 2026-08-22 — 도움말 해당 주제와 내용 일치 유지)
|
||
|
||
struct HealthGuideSheet: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
item(
|
||
symbol: "lock.shield.fill",
|
||
title: String(localized: "데이터는 이 기기 안에만 있어요"),
|
||
body: String(localized: "여기 보이는 값은 애플 건강 앱이 가진 데이터를 읽어 온 거예요. 하루 다님은 이 값을 서버로 보내거나 iCloud에 저장하지 않아요.")
|
||
)
|
||
item(
|
||
symbol: "clock.fill",
|
||
title: String(localized: "하루의 기준"),
|
||
body: String(localized: "걸음 수·운동 시간 같은 값은 설정의 '하루 시작 시간'을 기준으로 한 오늘 하루의 합이에요. 수면은 조금 달라요 — 잠은 보통 전날 밤에 시작되니까, 전날 밤부터 오늘 아침까지의 수면 구간을 통째로 '오늘 잔 것'으로 세요.")
|
||
)
|
||
item(
|
||
symbol: "arrow.triangle.2.circlepath",
|
||
title: String(localized: "값이 실시간이 아닐 수 있어요"),
|
||
body: String(localized: "애플워치에 쌓인 데이터는 워치와 아이폰이 동기화된 뒤에 보여요. 값이 안 맞아 보이면 잠시 뒤 다시 확인하거나 건강 앱을 한 번 열어 주세요.")
|
||
)
|
||
item(
|
||
symbol: "ipad.and.iphone",
|
||
title: String(localized: "기기마다 값이 다를 수 있어요"),
|
||
body: String(localized: "건강 데이터는 애플이 기기별로 관리해요. 아이패드는 시스템 설정에서 건강 iCloud 동기화를 켠 경우에만 아이폰과 같은 값이 보이고, 맥에서는 건강 데이터를 지원하지 않아요.")
|
||
)
|
||
item(
|
||
symbol: "hand.raised.fill",
|
||
title: String(localized: "값이 계속 비어 있나요?"),
|
||
body: String(localized: "읽기 권한이 꺼져 있으면 값이 0으로 보여요. 아이폰의 설정 → 개인정보 보호 및 보안 → 건강 → 하루 다님에서 권한을 확인할 수 있어요.")
|
||
)
|
||
}
|
||
.padding()
|
||
}
|
||
.navigationTitle("건강 데이터 안내")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button("닫기") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
|
||
private func item(symbol: String, title: String, body text: String) -> some View {
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Image(systemName: symbol)
|
||
.font(.title3)
|
||
.foregroundStyle(AppTheme.green)
|
||
.frame(width: 28)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(title)
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(text)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 일기 타임테이블 건강 블록 (1.5 추가분 — 수면·운동 실구간, HealthIntervals 소비)
|
||
|
||
/// 일기 하루 정리·내보내기의 타임테이블에 얹는 수면·운동 블록의 단일 공급 지점.
|
||
/// 색은 꼬리표 팔레트·'측정 중' 노랑과 겹치지 않는 지표 고정색 — 수면 인디고, 운동 주황.
|
||
enum DiaryHealthTimetable {
|
||
/// 숨김 저장 키 (standard defaults — 기기 로컬, 모든 날짜 공통. "sleep"/"workout" 쉼표 연결)
|
||
static let hiddenKey = "diary.timetableHealthHidden"
|
||
|
||
/// 블록 색 — 기본은 지표 고정색(수면 인디고·운동 주황)이지만, 꼬리표 색과 겹치면
|
||
/// 설정 → 건강 데이터 → '타임테이블 표시 색'에서 바꿀 수 있다(모든 타임테이블 공통, 1.5(4))
|
||
static var sleepColor: Color { customColor(LocalPrefsKeys.healthSleepColor) ?? .indigo }
|
||
static var workoutColor: Color { customColor(LocalPrefsKeys.healthWorkoutColor) ?? .orange }
|
||
|
||
private static func customColor(_ key: String) -> Color? {
|
||
guard let hex = AppGroup.defaults.string(forKey: key), !hex.isEmpty else { return nil }
|
||
return Color(hex: hex)
|
||
}
|
||
|
||
/// 수면 블록 병합 허용 간격 — 건강 앱 로우데이터는 수면 단계(코어·렘·깸)마다 쪼개져 있어
|
||
/// 그대로 그리면 블록이 칸칸이 갈라진다(1.5(4) 실기기 실측). 이 간격 이하의 짧은 깸은
|
||
/// 이어 붙여 한 덩어리로 그린다 — 사용자 규칙: "연속으로 잤으면 한 칸, 20분쯤 깼다 다시
|
||
/// 잤으면 나뉘는 게 맞다" → 10분 기준. 값(수면 합계)은 실제 잔 시간 그대로라 무영향
|
||
static let sleepDisplayMergeGap: TimeInterval = 10 * 60
|
||
|
||
static var hidden: Set<String> {
|
||
Set((UserDefaults.standard.string(forKey: hiddenKey) ?? "")
|
||
.split(separator: ",").map(String.init))
|
||
}
|
||
|
||
static func setHidden(_ set: Set<String>) {
|
||
UserDefaults.standard.set(set.sorted().joined(separator: ","), forKey: hiddenKey)
|
||
}
|
||
|
||
/// 그날 타임테이블에 얹을 건강 블록 — 화면(DiarySummaryPage)과 내보내기(DiaryExportRenderer)가
|
||
/// 같은 호출로 항상 같은 결과를 얻는다. 데이터 없는 기기(맥·미동기화 아이패드)는 자연히 빈 배열.
|
||
/// hidden은 화면이 @AppStorage 값을 넘겨 토글 즉시 재렌더되게 하는 용도 (기본=저장값)
|
||
static func blocks(dayKey: Date, math: DayMath,
|
||
hidden: Set<String>? = nil) -> [ExportTimetableData.Block] {
|
||
guard HealthDataStore.isAvailable else { return [] }
|
||
let hiddenSet = hidden ?? Self.hidden
|
||
let dayStart = math.dayRange(forKey: dayKey).lowerBound
|
||
func frac(_ date: Date) -> Double { date.timeIntervalSince(dayStart) / 3600 }
|
||
var blocks: [ExportTimetableData.Block] = []
|
||
if !hiddenSet.contains("sleep") {
|
||
let merged = HealthDataStore.mergedIntervals(
|
||
HealthIntervals.intervals(HealthIntervals.sleepKey, dayKey: dayKey)
|
||
.map { ($0.start, $0.end) },
|
||
tolerance: sleepDisplayMergeGap
|
||
)
|
||
for interval in merged {
|
||
blocks.append(ExportTimetableData.Block(
|
||
startFrac: frac(interval.0), endFrac: frac(interval.1),
|
||
color: sleepColor, symbol: HealthMetric.sleep.symbolName,
|
||
name: HealthMetric.sleep.name
|
||
))
|
||
}
|
||
}
|
||
if !hiddenSet.contains("workout") {
|
||
for key in HealthIntervals.workoutKeys(dayKey: dayKey).sorted() {
|
||
guard let kind = HealthWorkoutKind(rawValue: String(key.dropFirst("workout.".count)))
|
||
else { continue }
|
||
for interval in HealthIntervals.intervals(key, dayKey: dayKey) {
|
||
blocks.append(ExportTimetableData.Block(
|
||
startFrac: frac(interval.start), endFrac: frac(interval.end),
|
||
color: workoutColor, symbol: kind.symbolName, name: kind.name
|
||
))
|
||
}
|
||
}
|
||
}
|
||
return blocks
|
||
}
|
||
}
|
||
|
||
// MARK: - 일기 '건강 데이터' 카드 (1.5 추가분 — 하루 정리 섹션, 아이폰·아이패드 전용)
|
||
|
||
/// 그 날짜의 건강 지표 값 카드. 값은 HealthCache(기기 로컬)라 기기마다 다를 수 있고(§안내 시트),
|
||
/// 수면은 설정의 수면 표시 구간(SleepWindowPrefs — 요일별 포함)을 따른다.
|
||
struct DiaryHealthCard: View {
|
||
let dayKey: Date
|
||
|
||
@Environment(\.diaryReadOnly) private var readOnly
|
||
/// 카드 표시 지표 — 기기 로컬·전 날짜 공통. 비어 있으면 모음 탭 타일 선택을 물려받는다
|
||
@AppStorage("diary.healthMetrics") private var diaryMetricsRaw = ""
|
||
@AppStorage(LocalPrefsKeys.healthMetrics, store: AppGroup.defaults) private var tileMetricsRaw = ""
|
||
@State private var store = HealthDataStore.shared
|
||
@State private var showingPicker = false
|
||
|
||
private var metrics: [HealthMetric] {
|
||
HealthMetric.selectedList(raw: diaryMetricsRaw.isEmpty ? tileMetricsRaw : diaryMetricsRaw)
|
||
}
|
||
|
||
private func value(_ metric: HealthMetric) -> Double? {
|
||
let key = metric == .sleep
|
||
? "sleep@\(SleepWindowPrefs.spec(forDayKey: dayKey))"
|
||
: metric.rawValue
|
||
return HealthCache.value(key, dayKey: dayKey)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "heart.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.pink)
|
||
Text("건강 데이터")
|
||
.font(.subheadline.weight(.semibold))
|
||
Spacer(minLength: 0)
|
||
if !readOnly, store.hasRequestedAuth {
|
||
Button {
|
||
showingPicker = true
|
||
} label: {
|
||
Image(systemName: "slider.horizontal.3")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(AppTheme.green)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 5)
|
||
.background(AppTheme.green.opacity(0.12), in: Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text("표시할 지표 선택"))
|
||
}
|
||
}
|
||
if store.hasRequestedAuth {
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8) {
|
||
ForEach(metrics) { metric in
|
||
chip(metric)
|
||
}
|
||
}
|
||
} else {
|
||
Text("모음 탭에서 애플 건강을 연결하면 이 날의 걸음·운동·수면 데이터가 여기 보여요.")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(14)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
.sheet(isPresented: $showingPicker) {
|
||
HealthMetricPickerSheet(metricsRaw: $diaryMetricsRaw, fallbackRaw: tileMetricsRaw)
|
||
}
|
||
.task { await store.refreshToday() }
|
||
}
|
||
|
||
private func chip(_ metric: HealthMetric) -> some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 5) {
|
||
Image(safeSymbol: metric.symbolName)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.pink)
|
||
Text(metric.name)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
if let value = value(metric) {
|
||
// ja "7時間28分"처럼 긴 값이 칩 폭에서 말줄임되지 않게 살짝 축소 허용 (1.5(4) 실측)
|
||
Text(metric.valueLabel(value))
|
||
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.7)
|
||
} else {
|
||
Text(verbatim: "–")
|
||
.font(.system(.subheadline, design: .rounded).weight(.bold))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value(metric).map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
|
||
}
|
||
}
|
||
|
||
// MARK: - 수면 표시 구간 설정 (1.5 추가분 — 설정 탭, SleepWindowPrefs)
|
||
|
||
/// 모음 탭 타일·일기 건강 카드의 수면 창 설정. 교대·야간 근무처럼 낮에 자는 패턴은
|
||
/// 전체 구간을 넓히거나(시작=끝이면 24시간) 요일별 구간으로 맞춘다. 다짐의 수면 구간은
|
||
/// 다짐별 저장이라 그대로 두되, 새 수면 다짐의 기본값은 여기 전체 구간을 따른다.
|
||
struct SleepWindowSettingsView: View {
|
||
@AppStorage(LocalPrefsKeys.sleepWindowGlobal, store: AppGroup.defaults)
|
||
private var globalRaw = ""
|
||
@AppStorage(LocalPrefsKeys.sleepWindowWeekdayEnabled, store: AppGroup.defaults)
|
||
private var weekdayEnabled = false
|
||
/// 요일별 스펙 사본 — 변경 즉시 defaults에 반영
|
||
@State private var weekdaySpecs: [String: String] = SleepWindowPrefs.weekdaySpecs
|
||
|
||
/// 표시 순서: 월~일 (한국 관습 — 주 시작 요일 설정과 무관한 목록 순서일 뿐)
|
||
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
SleepWindowEditorRows(spec: Binding(
|
||
get: { globalRaw.isEmpty ? SleepWindowPrefs.defaultSpec : globalRaw },
|
||
set: { globalRaw = $0 }
|
||
))
|
||
} header: {
|
||
Text("전체 구간")
|
||
} footer: {
|
||
Text("이 구간 안에서 잔 시간이 '구간이 끝나는 날'의 수면으로 집계돼요. 기본은 저녁 9시부터 다음 날 아침 9시까지예요. 시작과 끝을 같은 시각으로 두면 하루 전체(24시간)를 봐요.")
|
||
}
|
||
Section {
|
||
Toggle(isOn: $weekdayEnabled.animation()) {
|
||
Text("요일마다 다르게")
|
||
}
|
||
.tint(AppTheme.green)
|
||
if weekdayEnabled {
|
||
ForEach(weekdayOrder, id: \.self) { weekday in
|
||
weekdayGroup(weekday)
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("야간 근무 등으로 특정 요일엔 낮에 잔다면, 그 요일만 구간을 따로 정할 수 있어요. 정하지 않은 요일은 전체 구간을 따라요.\n이 설정은 모음 탭 타일과 일기의 건강 카드에 적용돼요. 다짐의 수면 구간은 다짐 편집에서 따로 정해요 — 새로 만드는 수면 다짐은 여기의 전체 구간으로 시작해요.")
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("수면 표시 구간")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func weekdayGroup(_ weekday: Int) -> some View {
|
||
let binding = Binding<String>(
|
||
get: {
|
||
let raw = weekdaySpecs["\(weekday)"] ?? ""
|
||
return raw.isEmpty ? (globalRaw.isEmpty ? SleepWindowPrefs.defaultSpec : globalRaw) : raw
|
||
},
|
||
set: { newValue in
|
||
weekdaySpecs["\(weekday)"] = newValue
|
||
AppGroup.defaults.set(weekdaySpecs, forKey: LocalPrefsKeys.sleepWindowByWeekday)
|
||
}
|
||
)
|
||
DisclosureGroup {
|
||
SleepWindowEditorRows(spec: binding)
|
||
} label: {
|
||
HStack {
|
||
Text("\(Format.weekdayShort(weekday))요일")
|
||
Spacer()
|
||
Text(Self.summary(binding.wrappedValue))
|
||
.font(.caption)
|
||
.foregroundStyle(weekdaySpecs["\(weekday)"].map { $0.isEmpty } ?? true ? .secondary : AppTheme.green)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// "1260-540" → "21:00 ~ 다음 날 09:00" 요약
|
||
static func summary(_ spec: String) -> String {
|
||
let parts = spec.split(separator: "-").compactMap { Int(String($0)) }
|
||
guard parts.count == 2 else { return "" }
|
||
func hhmm(_ minutes: Int) -> String {
|
||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
||
}
|
||
if parts[0] == parts[1] {
|
||
return String(localized: "\(hhmm(parts[1]))에 끝나는 24시간")
|
||
}
|
||
return parts[0] > parts[1]
|
||
? String(localized: "\(hhmm(parts[0])) ~ 다음 날 \(hhmm(parts[1]))")
|
||
: String(localized: "\(hhmm(parts[0])) ~ \(hhmm(parts[1]))")
|
||
}
|
||
}
|
||
|
||
// MARK: - 타임테이블 표시 색 설정 (1.5(4) — 수면·운동 블록 색, 모든 타임테이블 공통)
|
||
|
||
/// 수면·운동 블록의 표시 색. 기본은 지표 고정색(인디고·주황)이지만 꼬리표 색과 겹치면
|
||
/// 여기서 바꾼다 — 일기·기록 탭 타임테이블과 내보내기에 모두 적용(단일 공급 지점이
|
||
/// DiaryHealthTimetable.sleepColor/workoutColor라 자동 일치).
|
||
struct HealthColorSettingsView: View {
|
||
@AppStorage(LocalPrefsKeys.healthSleepColor, store: AppGroup.defaults)
|
||
private var sleepHex = ""
|
||
@AppStorage(LocalPrefsKeys.healthWorkoutColor, store: AppGroup.defaults)
|
||
private var workoutHex = ""
|
||
|
||
private func binding(_ hex: Binding<String>, fallback: Color) -> Binding<Color> {
|
||
Binding(
|
||
get: { hex.wrappedValue.isEmpty ? fallback : Color(hex: hex.wrappedValue) },
|
||
set: { hex.wrappedValue = $0.hexString }
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
ColorPicker(selection: binding($sleepHex, fallback: .indigo), supportsOpacity: false) {
|
||
Label {
|
||
Text("수면")
|
||
} icon: {
|
||
Image(systemName: HealthMetric.sleep.symbolName)
|
||
.foregroundStyle(DiaryHealthTimetable.sleepColor)
|
||
}
|
||
}
|
||
ColorPicker(selection: binding($workoutHex, fallback: .orange), supportsOpacity: false) {
|
||
Label {
|
||
Text("운동")
|
||
} icon: {
|
||
Image(systemName: "figure.run")
|
||
.foregroundStyle(DiaryHealthTimetable.workoutColor)
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("일기·기록 탭 타임테이블에 깔리는 수면·운동 블록의 색이에요. 꼬리표 색과 비슷해서 헷갈리면 바꿔 보세요 — 모든 타임테이블과 내보내기에 함께 적용돼요.")
|
||
}
|
||
if !sleepHex.isEmpty || !workoutHex.isEmpty {
|
||
Section {
|
||
Button("기본 색으로 되돌리기") {
|
||
sleepHex = ""
|
||
workoutHex = ""
|
||
}
|
||
.font(.callout)
|
||
}
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("타임테이블 표시 색")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
|
||
/// 수면 구간 스펙("시작분-끝분") 하나를 편집하는 시작·끝 휠 두 줄
|
||
private struct SleepWindowEditorRows: View {
|
||
@Binding var spec: String
|
||
|
||
private func minutes(_ index: Int) -> Int {
|
||
let parts = spec.split(separator: "-").compactMap { Int(String($0)) }
|
||
guard parts.count == 2 else { return index == 0 ? 1260 : 540 }
|
||
return parts[index]
|
||
}
|
||
|
||
private func dateBinding(_ index: Int) -> Binding<Date> {
|
||
Binding(
|
||
get: {
|
||
Calendar.current.date(
|
||
byAdding: .minute, value: minutes(index),
|
||
to: Calendar.current.startOfDay(for: .now)
|
||
) ?? .now
|
||
},
|
||
set: { newValue in
|
||
let comps = Calendar.current.dateComponents([.hour, .minute], from: newValue)
|
||
let value = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||
let start = index == 0 ? value : minutes(0)
|
||
let end = index == 1 ? value : minutes(1)
|
||
spec = "\(start)-\(end)"
|
||
}
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
CollapsibleTimeWheel(
|
||
label: String(localized: "구간 시작"),
|
||
selection: dateBinding(0),
|
||
components: .hourAndMinute
|
||
)
|
||
CollapsibleTimeWheel(
|
||
label: String(localized: "구간 끝"),
|
||
selection: dateBinding(1),
|
||
components: .hourAndMinute
|
||
)
|
||
}
|
||
}
|