// // QuestEditorView.swift // Haru_Danim // // 다짐 추가/수정: ①대상(행동/꼬리표) ②주기 ③목표량+방향 (CLAUDE.md §6.4) // import SwiftUI import SwiftData struct QuestEditorView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @Query(sort: \Action.createdAt) private var allActionsQuery: [Action] @Query(sort: \Tag.createdAt) private var allTags: [Tag] // 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고) private var allActions: [Action] { LocalPrefs.orderedActions(allActionsQuery) } let goal: Goal /// nil이면 새 다짐 let quest: Quest? enum TargetKind: String, CaseIterable, Identifiable { case action, tag, health var id: String { rawValue } var label: String { switch self { case .action: return String(localized: "행동") case .tag: return String(localized: "꼬리표") case .health: return String(localized: "건강") } } } // ① 대상 @State private var targetKind: TargetKind = .action @State private var selectedAction: Action? @State private var selectedTag: Tag? @State private var measure: TrackingType = .time // ① 건강 대상 (1.5 — 프리미엄, 하루 단위 전용. plan §3.4) private let premium = PremiumManager.shared /// 지표 선택 raw — HealthMetric raw 또는 "workout"(종목별 운동) @State private var healthChoiceRaw: String = HealthMetric.steps.rawValue @State private var healthWorkoutKind: HealthWorkoutKind = .running /// 수면 구간 (수면 지표 전용 — 구간 끝이 속한 논리적 하루에 통귀속). /// 새 다짐의 기본값은 설정의 '수면 표시 구간' 전체 값을 따른다 (1.5 추가분 — /// 기존 다짐은 load()가 저장값으로 덮어써 무영향) @State private var sleepStart: Date = { let minutes = SleepWindowPrefs.globalSpec.split(separator: "-").compactMap { Int(String($0)) } return Calendar.current.date( byAdding: .minute, value: minutes.first ?? 1260, to: Calendar.current.startOfDay(for: .now) ) ?? .now }() @State private var sleepEnd: Date = { let minutes = SleepWindowPrefs.globalSpec.split(separator: "-").compactMap { Int(String($0)) } return Calendar.current.date( byAdding: .minute, value: minutes.count == 2 ? minutes[1] : 540, to: Calendar.current.startOfDay(for: .now) ) ?? .now }() /// 건강 다짐 표시 색 (1.5(7)) — 기본 분홍(.pink). 저장 시 기본색 그대로면 빈 값으로 /// 정규화해 시스템 분홍의 라이트/다크 적응을 유지한다 (save() 참고) @State private var healthColor: Color = .pink /// 수량형 건강 목표 입력값 (지표별 기본 단위 — 걸음 보 / 에너지 kcal / 스탠드 시간) @State private var healthIntTarget = 8000 /// 거리 목표 (km — 저장은 m) @State private var healthKmTarget = 5.0 /// 물 목표 (L — 저장은 ml) @State private var healthLitersTarget = 2.0 /// 현재 편집 중인 건강 대상 private var healthTargetSelection: HealthQuestTarget { if healthChoiceRaw == "workout" { return .workout(healthWorkoutKind) } return .metric(HealthMetric(rawValue: healthChoiceRaw) ?? .steps) } // ② 주기 @State private var period: QuestPeriod = .daily @State private var scheduleMode: QuestScheduleMode = .everyDay @State private var weekdays: Set = [] @State private var monthDays: Set = [] @State private var ordinalWeek = 1 @State private var ordinalWeekday = 2 @State private var customStart: Date = .now @State private var customEnd: Date = .now // ② 마감 시각 (하루 단위 전용 — 그날의 집계 창을 [하루 시작, 마감]으로 줄인다) @State private var hasDeadline = false @State private var deadlineTime: Date = Calendar.current.date( bySettingHour: 21, minute: 0, second: 0, of: .now ) ?? .now // ③ 목표량 + 방향 @State private var targetHours = 1 @State private var targetMinutes = 0 @State private var targetCount = 1 @State private var direction: QuestDirection = .atLeast var body: some View { NavigationStack { Form { targetSection periodSection amountSection } .navigationTitle(quest == nil ? String(localized: "다짐 추가") : String(localized: "다짐 수정")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("취소") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("저장") { save() } .disabled(!isValid) } } .onAppear(perform: load) } } // MARK: ① 대상 선택 private var targetSection: some View { Section { Picker("대상 종류", selection: $targetKind.animation()) { ForEach(TargetKind.allCases) { kind in Text(kind.label).tag(kind) } } .pickerStyle(.segmented) .onChange(of: targetKind) { // 건강 다짐은 하루 단위 전용 — 주기를 강제하고 마감 시각도 함께 해제 (plan §8ⓔ) if targetKind == .health { period = .daily hasDeadline = false } } switch targetKind { case .health: if !premium.isPremium { Label { VStack(alignment: .leading, spacing: 2) { Text("건강 다짐은 프리미엄 기능이에요") Text("설정 → 프리미엄에서 잠금을 해제하면 걸음·수면·운동 종목 같은 건강 데이터로 다짐을 만들 수 있어요.") .font(.caption) .foregroundStyle(.secondary) } } icon: { Image(systemName: "crown.fill") .foregroundStyle(AppTheme.yellow) } } else { Picker("지표", selection: $healthChoiceRaw.animation()) { ForEach(HealthMetric.allCases) { metric in Label(metric.name, systemImage: metric.symbolName).tag(metric.rawValue) } Label(String(localized: "운동 종목별"), systemImage: "figure.run.square.stack") .tag("workout") } // 새 다짐에서만 지표별 기본 목표량 자동 채움 — 기존 다짐 편집의 // onAppear 로드가 onChange를 발화시켜 저장된 값을 덮는 것 방지 .onChange(of: healthChoiceRaw) { if quest == nil { applyHealthDefaults() } } if healthChoiceRaw == "workout" { Picker("종목", selection: $healthWorkoutKind) { ForEach(HealthWorkoutKind.allCases) { kind in Label(kind.name, systemImage: kind.symbolName).tag(kind) } } } if healthChoiceRaw == HealthMetric.sleep.rawValue { CollapsibleTimeWheel( label: String(localized: "수면 구간 시작"), selection: $sleepStart, components: .hourAndMinute ) CollapsibleTimeWheel( label: String(localized: "수면 구간 끝"), selection: $sleepEnd, components: .hourAndMinute ) } // 표시 색 (1.5(7)) — 목표 탭·모음 카드·일기·위젯에서 이 다짐이 보일 색. // 행동·꼬리표 다짐은 대상의 색을 따르므로 건강 다짐에만 있는 선택이고, // 타임테이블의 수면·운동 블록 색(설정 → 건강 데이터)과는 별개다 ColorPicker(selection: $healthColor, supportsOpacity: false) { Text("다짐 색") } LazyVGrid(columns: [GridItem(.adaptive(minimum: 40), spacing: 10)], spacing: 10) { ForEach(Array(AppTheme.tagPresets.enumerated()), id: \.offset) { index, hex in Button { healthColor = Color(hex: hex) } label: { Circle() .fill(Color(hex: hex)) .frame(width: 36, height: 36) .overlay { if healthColor.hexString == hex { Image(systemName: "checkmark") .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) } } } .buttonStyle(.plain) .accessibilityLabel(String(localized: "미리 설정된 색 \(index + 1)")) .accessibilityAddTraits(healthColor.hexString == hex ? .isSelected : []) } } .padding(.vertical, 4) } case .action: if allActions.isEmpty { Text("등록된 행동이 없어요").foregroundStyle(.secondary) } else { Picker("행동", selection: $selectedAction) { Text("선택").tag(Action?.none) ForEach(allActions) { action in Label(action.name, systemImage: SymbolCompat.safe(action.symbolName)) .tag(Optional(action)) } } } case .tag: if allTags.isEmpty { Text("등록된 꼬리표가 없어요").foregroundStyle(.secondary) } else { Picker("꼬리표", selection: $selectedTag) { Text("선택").tag(Tag?.none) ForEach(allTags) { tag in Text(tag.name).tag(Optional(tag)) } } Picker("측정 기준", selection: $measure) { ForEach(TrackingType.allCases) { type in Text(type.label).tag(type) } } } } } header: { Text("① 대상 선택") } footer: { if targetKind == .health { if healthChoiceRaw == HealthMetric.sleep.rawValue { Text("잠은 보통 전날 밤에 시작되니까, 이 수면 구간 안에서 잔 시간을 통째로 '그날 잔 것'으로 세요. 값은 애플 건강 앱에서 읽어 오고 기기 밖으로 나가지 않아요.") } else if healthChoiceRaw == "workout" { Text("건강 앱에 기록된 운동 중 선택한 종목의 시간을 세요. '운동 시간' 지표(활동 링 기준)와는 값이 다를 수 있어요. 값은 기기 밖으로 나가지 않아요.") } else { Text("애플 건강 앱의 데이터를 읽어 하루 목표를 재요. 값은 기기 밖으로 나가지 않고, 건강 다짐은 하루 단위로만 반복돼요.") } } } } /// 지표를 바꿀 때 그 지표에 맞는 기본 목표량을 채워 준다 (새 다짐 편집 편의) private func applyHealthDefaults() { switch healthTargetSelection { case .metric(.steps): healthIntTarget = 8000 case .metric(.activeEnergy): healthIntTarget = 500 case .metric(.standHours): healthIntTarget = 12 case .metric(.distance): healthKmTarget = 5.0 case .metric(.water): healthLitersTarget = 2.0 case .metric(.sleep): targetHours = 8; targetMinutes = 0 case .metric(.exerciseMinutes), .workout: targetHours = 0; targetMinutes = 30 case .metric(.mindfulMinutes): targetHours = 0; targetMinutes = 10 } } // MARK: ② 주기 선택 @ViewBuilder private var periodSection: some View { Section { // 건강 다짐은 하루 단위 전용 (plan §3.4 — 주간·월간 합산은 v1 제외) if targetKind == .health { HStack { Text("반복 주기") Spacer() Text("하루 (건강 다짐 전용)") .foregroundStyle(.secondary) } } else { Picker("반복 주기", selection: $period.animation()) { ForEach(QuestPeriod.allCases) { p in Text(p.label).tag(p) } } } if period == .daily { Picker("적용일", selection: $scheduleMode.animation()) { ForEach(QuestScheduleMode.allCases) { mode in Text(mode.label).tag(mode) } } switch scheduleMode { case .everyDay: EmptyView() case .weekdays: weekdayPicker case .monthDays: monthDayPicker case .ordinalWeekday: // "n번째 ◯요일" (weekdayOrdinal) — 달력의 'n째 주'와 다를 수 있어 문구를 맞춘다 Picker("몇 번째", selection: $ordinalWeek) { ForEach(1...5, id: \.self) { n in Text("\(n)번째").tag(n) } } Picker("요일", selection: $ordinalWeekday) { ForEach(1...7, id: \.self) { w in Text("\(Format.weekdayShort(w))요일").tag(w) } } } // 마감 시각: 그날의 집계 창을 [하루 시작, 마감]으로 줄인다 (하루 단위 전용). // 건강 다짐은 미지원 — 캐시가 하루 단위 값이라 부분 창을 잴 수 없다 (§8ⓔ 확정) if targetKind != .health { Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation()) if hasDeadline { CollapsibleTimeWheel( label: String(localized: "마감 시각"), selection: $deadlineTime, components: .hourAndMinute ) } } } if period == .custom { DatePicker("시작", selection: $customStart, displayedComponents: .date) DatePicker("끝", selection: $customEnd, in: customStart..., displayedComponents: .date) } } header: { Text("② 주기 선택") } footer: { if period == .daily && hasDeadline { Text("하루 시작부터 마감 시각까지의 기록만 이 다짐에 집계돼요. 마감 이후의 기록은 기록 탭에 남지만 이 다짐에는 반영되지 않아요. 하루 시작 시간보다 이른 시각은 다음 날 새벽(그 하루가 끝나기 전)을 뜻해요.") } } } private var weekdayPicker: some View { HStack(spacing: 6) { ForEach(1...7, id: \.self) { weekday in let on = weekdays.contains(weekday) Button { if on { weekdays.remove(weekday) } else { weekdays.insert(weekday) } } label: { Text(Format.weekdayShort(weekday)) .font(.subheadline.weight(.semibold)) .frame(width: 36, height: 36) .background(on ? AppTheme.green : AppTheme.surface, in: Circle()) .foregroundStyle(on ? Color.white : Color.primary) } .buttonStyle(.plain) } } .frame(maxWidth: .infinity) } private var monthDayPicker: some View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 7), spacing: 6) { ForEach(1...31, id: \.self) { day in let on = monthDays.contains(day) Button { if on { monthDays.remove(day) } else { monthDays.insert(day) } } label: { Text("\(day)") .font(.footnote.weight(.medium)) .frame(width: 34, height: 34) .background(on ? AppTheme.green : AppTheme.surface, in: Circle()) .foregroundStyle(on ? Color.white : Color.primary) } .buttonStyle(.plain) } } .padding(.vertical, 4) } // MARK: ③ 목표량 + 방향 private var effectiveMeasure: TrackingType { switch targetKind { case .action: return selectedAction?.trackingType ?? .time case .tag: return measure case .health: return healthTargetSelection.isDuration ? .time : .count } } @ViewBuilder private var amountSection: some View { Section { if targetKind == .health, !healthTargetSelection.isDuration { // 수량형 건강 목표 — 지표 고유 단위 입력 (스테퍼로 못 미치는 큰 수는 직접 입력) switch healthTargetSelection { case .metric(.steps): LabeledContent("목표 걸음 수") { TextField("8000", value: $healthIntTarget, format: .number) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) } case .metric(.activeEnergy): LabeledContent("목표 kcal") { TextField("500", value: $healthIntTarget, format: .number) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) } case .metric(.distance): LabeledContent("목표 거리 (km)") { TextField("5", value: $healthKmTarget, format: .number.precision(.fractionLength(0...1))) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) } case .metric(.water): LabeledContent("목표 물 (L)") { TextField("2", value: $healthLitersTarget, format: .number.precision(.fractionLength(0...1))) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) } case .metric(.standHours): Stepper(value: $healthIntTarget, in: 1...24) { HStack { Text("목표 일어서기") Spacer() Text("\(healthIntTarget)시간").foregroundStyle(.secondary) } } default: EmptyView() } } else if effectiveMeasure == .time { HStack { Picker("시간", selection: $targetHours) { ForEach(0...23, id: \.self) { h in Text("\(h)시간").tag(h) } } .pickerStyle(.wheel) .frame(maxWidth: .infinity) Picker("분", selection: $targetMinutes) { ForEach(Array(stride(from: 0, through: 55, by: 5)), id: \.self) { m in Text("\(m)분").tag(m) } } .pickerStyle(.wheel) .frame(maxWidth: .infinity) } .frame(height: 110) } else { Stepper(value: $targetCount, in: 1...9999) { HStack { Text("목표 횟수") Spacer() Text("\(targetCount)회").foregroundStyle(.secondary) } } } Picker("방향", selection: $direction) { ForEach(QuestDirection.allCases) { dir in Text(dir.label).tag(dir) } } .pickerStyle(.segmented) } header: { Text("③ 목표량과 방향") } footer: { Text(direction == .atLeast ? "주기마다 목표량 이상을 달성하는 것이 목표예요." : "주기마다 목표량을 넘지 않는 것이 목표예요.") } } // MARK: 저장 private var isValid: Bool { switch targetKind { case .action: guard selectedAction != nil else { return false } case .tag: guard selectedTag != nil else { return false } case .health: guard premium.isPremium else { return false } switch healthTargetSelection { case .metric(.steps), .metric(.activeEnergy), .metric(.standHours): guard healthIntTarget > 0 else { return false } case .metric(.distance): guard healthKmTarget > 0 else { return false } case .metric(.water): guard healthLitersTarget > 0 else { return false } default: break // 시간류는 아래 공통 시간 검증 } } if period == .daily { switch scheduleMode { case .weekdays: if weekdays.isEmpty { return false } case .monthDays: if monthDays.isEmpty { return false } default: break } } if period == .custom && customEnd < customStart { return false } if effectiveMeasure == .time && targetHours == 0 && targetMinutes == 0 { return false } return true } private func load() { guard let quest else { return } if let action = quest.targetAction { targetKind = .action selectedAction = action } else if let tag = quest.targetTag { targetKind = .tag selectedTag = tag } else if let health = quest.healthTarget { targetKind = .health switch health { case .metric(let metric): healthChoiceRaw = metric.rawValue switch metric { case .steps, .activeEnergy, .standHours: healthIntTarget = quest.targetCount case .distance: healthKmTarget = Double(quest.targetCount) / 1000 case .water: healthLitersTarget = Double(quest.targetCount) / 1000 default: break } case .workout(let kind): healthChoiceRaw = "workout" healthWorkoutKind = kind } let cal = Calendar.current let dayStart = cal.startOfDay(for: .now) sleepStart = cal.date(byAdding: .minute, value: quest.sleepWindowStartMinutes, to: dayStart) ?? sleepStart sleepEnd = cal.date(byAdding: .minute, value: quest.sleepWindowEndMinutes, to: dayStart) ?? sleepEnd if !quest.healthColorHex.isEmpty { healthColor = Color(hex: quest.healthColorHex) } } measure = quest.measure period = quest.period scheduleMode = quest.scheduleMode weekdays = Set(quest.weekdays) monthDays = Set(quest.monthDays) ordinalWeek = quest.ordinalWeek ordinalWeekday = quest.ordinalWeekday customStart = quest.customStart ?? .now customEnd = quest.customEnd ?? .now if quest.deadlineMinutes >= 0 { hasDeadline = true let cal = Calendar.current deadlineTime = cal.date(byAdding: .minute, value: quest.deadlineMinutes, to: cal.startOfDay(for: .now)) ?? deadlineTime } targetHours = Int(quest.targetSeconds) / 3600 targetMinutes = (Int(quest.targetSeconds) % 3600) / 60 targetCount = quest.targetCount direction = quest.direction } private func save() { let target = quest ?? Quest(goal: goal) switch targetKind { case .action: target.targetAction = selectedAction target.targetTag = nil target.measure = selectedAction?.trackingType ?? .time target.healthMetricRaw = "" // 정규화 — 반대편 대상 정리 (plan §3.4) target.healthColorHex = "" case .tag: target.targetTag = selectedTag target.targetAction = nil target.measure = measure target.healthMetricRaw = "" target.healthColorHex = "" case .health: target.targetAction = nil target.targetTag = nil let health = healthTargetSelection target.healthMetricRaw = health.raw target.measure = health.isDuration ? .time : .count switch health { case .metric(.steps), .metric(.activeEnergy), .metric(.standHours): target.targetCount = max(1, healthIntTarget) case .metric(.distance): target.targetCount = max(1, Int((healthKmTarget * 1000).rounded())) case .metric(.water): target.targetCount = max(1, Int((healthLitersTarget * 1000).rounded())) default: break // 시간류는 아래 공통 targetSeconds 저장이 담당 } let cal = Calendar.current let startComps = cal.dateComponents([.hour, .minute], from: sleepStart) let endComps = cal.dateComponents([.hour, .minute], from: sleepEnd) target.sleepWindowStartMinutes = (startComps.hour ?? 21) * 60 + (startComps.minute ?? 0) target.sleepWindowEndMinutes = (endComps.hour ?? 9) * 60 + (endComps.minute ?? 0) // 표시 색 — 기본 분홍 그대로면 빈 값(시스템 분홍의 다크 모드 적응 유지), // 사용자가 바꿨을 때만 hex 고정 저장 (1.5(7)) target.healthColorHex = healthColor.hexString == Color.pink.hexString ? "" : healthColor.hexString period = .daily // 건강 다짐은 하루 단위 전용 (방어 — UI에서도 강제됨) } target.period = period target.scheduleMode = period == .daily ? scheduleMode : .everyDay // 마감 시각은 하루 단위 전용 — 주기를 바꾸거나 토글을 끄면 함께 해제한다 if period == .daily && hasDeadline { let comps = Calendar.current.dateComponents([.hour, .minute], from: deadlineTime) target.deadlineMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) } else { target.deadlineMinutes = -1 } target.weekdays = Array(weekdays).sorted() target.monthDays = Array(monthDays).sorted() target.ordinalWeek = ordinalWeek target.ordinalWeekday = ordinalWeekday target.customStart = period == .custom ? customStart : nil target.customEnd = period == .custom ? customEnd : nil target.targetSeconds = Double(targetHours * 3600 + targetMinutes * 60) // 수량형 건강 다짐의 targetCount는 위 switch에서 지표 단위로 이미 저장했다 — 덮지 않음 if !(targetKind == .health && !healthTargetSelection.isDuration) { target.targetCount = targetCount } target.direction = direction if quest == nil { target.sortOrder = (goal.quests.map(\.sortOrder).max() ?? -1) + 1 context.insert(target) } DataChange.commit(context: context) dismiss() } }