// // 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 var id: String { rawValue } var label: String { self == .action ? String(localized: "행동") : String(localized: "꼬리표") } } // ① 대상 @State private var targetKind: TargetKind = .action @State private var selectedAction: Action? @State private var selectedTag: Tag? @State private var measure: TrackingType = .time // ② 주기 @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) switch targetKind { case .action: if allActions.isEmpty { Text("등록된 행동이 없어요").foregroundStyle(.secondary) } else { Picker("행동", selection: $selectedAction) { Text("선택").tag(Action?.none) ForEach(allActions) { action in Label(action.name, systemImage: 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) } } } } } } // MARK: ② 주기 선택 @ViewBuilder private var periodSection: some View { Section { 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) } } } // 마감 시각: 그날의 집계 창을 [하루 시작, 마감]으로 줄인다 (하루 단위 전용) Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation()) if hasDeadline { DatePicker("마감 시각", selection: $deadlineTime, displayedComponents: .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 { targetKind == .action ? (selectedAction?.trackingType ?? .time) : measure } @ViewBuilder private var amountSection: some View { Section { 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 { let hasTarget = targetKind == .action ? selectedAction != nil : selectedTag != nil guard hasTarget else { return false } 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 } 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 case .tag: target.targetTag = selectedTag target.targetAction = nil target.measure = measure } 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) 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() } }