mycode/myApp/HaruDanim/IOS/Views/HealthSection.swift
songyc macbook 8b1bc44f67 fix+feat(1.5-p7): 소킹 실측 2건 수정 + 추가 2건 — 빌드 5
- 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
2026-08-22 23:24:13 +09:00

720 lines
32 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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.

//
// 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 "728" (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
)
}
}