- Quest 필드 3종(healthMetricRaw·수면 구간, CloudKit 기본값 규칙)+행동/꼬리표 우선 정규화 - HealthQuestTarget/HealthWorkoutKind(종목 큐레이션)·HealthCache 공유 계층 이전(위젯 읽기) - QuestProgress 값 공급 단일 지점 3곳(value·spanValue·perDayValues)만 분기 — 기존 49건 무변경 그린 - 편집기 대상 3유형째(지표·종목·수면 구간 휠·단위별 목표 입력·프리미엄 게이트·하루 전용) - §8ⓒⓕ: 데이터 없는 기기 모수 제외+'데이터 없음' 표기(목표 행·모음 카드·위젯 ②③④) +자동/수동 판정 보류, 위젯 값 라벨 지표 단위화, HealthStore 백필(400일)·운동 조회·부트스트랩 - 검증: progressSelfTest 61건(H 시리즈 12 신규)·20·27·10 ALL PASS(26.5+18.5), Debug/Store/워치 빌드, 시각 QA(목표 행·편집기·타일). 신규 인자 3종 §14 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
583 lines
26 KiB
Swift
583 lines
26 KiB
Swift
//
|
|
// 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
|
|
/// 수면 구간 (수면 지표 전용 — 구간 끝이 속한 논리적 하루에 통귀속)
|
|
@State private var sleepStart: Date = Calendar.current.date(
|
|
bySettingHour: 21, minute: 0, second: 0, of: .now
|
|
) ?? .now
|
|
@State private var sleepEnd: Date = Calendar.current.date(
|
|
bySettingHour: 9, minute: 0, second: 0, of: .now
|
|
) ?? .now
|
|
/// 수량형 건강 목표 입력값 (지표별 기본 단위 — 걸음 보 / 에너지 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<Int> = []
|
|
@State private var monthDays: Set<Int> = []
|
|
@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
|
|
)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
case .tag:
|
|
target.targetTag = selectedTag
|
|
target.targetAction = nil
|
|
target.measure = measure
|
|
target.healthMetricRaw = ""
|
|
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)
|
|
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()
|
|
}
|
|
}
|