// // 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 { Set((UserDefaults.standard.string(forKey: hiddenKey) ?? "") .split(separator: ",").map(String.init)) } static func setHidden(_ set: Set) { UserDefaults.standard.set(set.sorted().joined(separator: ","), forKey: hiddenKey) } /// 그날 타임테이블에 얹을 건강 블록 — 화면(DiarySummaryPage)과 내보내기(DiaryExportRenderer)가 /// 같은 호출로 항상 같은 결과를 얻는다. 데이터 없는 기기(맥·미동기화 아이패드)는 자연히 빈 배열. /// hidden은 화면이 @AppStorage 값을 넘겨 토글 즉시 재렌더되게 하는 용도 (기본=저장값) static func blocks(dayKey: Date, math: DayMath, hidden: Set? = 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( 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, fallback: Color) -> Binding { 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 { 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 ) } }