diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift index de3375d..2169772 100644 --- a/myApp/HaruDanim/IOS/Views/GoalViews.swift +++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift @@ -15,7 +15,13 @@ struct GoalListView: View { @Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal] private let premium = PremiumManager.shared - @State private var showingAdd = false + @State private var showingAdd: Bool = { + #if DEBUG + // 검증용: -goalShowEditor YES → 목표 추가 화면 바로 표시 + if UserDefaults.standard.bool(forKey: "goalShowEditor") { return true } + #endif + return false + }() @State private var showLimitAlert = false @State private var showingPremiumSheet = false @State private var isReordering = false @@ -459,7 +465,11 @@ struct GoalDetailView: View { } .foregroundStyle(AppTheme.yellow) } footer: { - Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.") + if goal.achieveThresholdPercent < 100 { + Text("종료 시 다짐 평균 달성률이 \(goal.achieveThresholdPercent)% 이상이면 달성으로 판정돼요.") + } else { + Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.") + } } } Section { @@ -559,6 +569,7 @@ struct GoalEditorView: View { @State private var startDate: Date = .now @State private var hasEndDate = false @State private var endDate: Date = .now + @State private var achieveThreshold = 100 @State private var showingSymbolPicker = false var body: some View { @@ -601,6 +612,27 @@ struct GoalEditorView: View { ) } } + Section { + HStack { + Text("다짐 평균 달성률") + Spacer() + Text(verbatim: "\(achieveThreshold)%") + .font(.body.weight(.semibold).monospacedDigit()) + .foregroundStyle(achieveThreshold == 100 ? Color.secondary : AppTheme.green) + } + Slider( + value: Binding( + get: { Double(achieveThreshold) }, + set: { achieveThreshold = Int($0.rounded()) } + ), + in: 10...100, + step: 5 + ) + } header: { + Text("달성 판정 기준") + } footer: { + Text("목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'로 판정해요. 100%면 모든 다짐을 채워야 달성이에요. 다짐이 없는 목표는 지금처럼 직접 선택해요.") + } } .navigationTitle(goal == nil ? String(localized: "목표 추가") : String(localized: "목표 수정")) .navigationBarTitleDisplayMode(.inline) @@ -626,6 +658,7 @@ struct GoalEditorView: View { symbolName = goal.symbolName color = goal.color startDate = goal.startDate + achieveThreshold = goal.achieveThresholdPercent if let end = goal.endDate { hasEndDate = true endDate = end @@ -640,6 +673,7 @@ struct GoalEditorView: View { goal.colorHex = color.hexString goal.startDate = startDate goal.endDate = hasEndDate ? endDate : nil + goal.achieveThresholdPercent = achieveThreshold } else { let newGoal = Goal( title: trimmed, @@ -648,6 +682,7 @@ struct GoalEditorView: View { startDate: startDate, endDate: hasEndDate ? endDate : nil ) + newGoal.achieveThresholdPercent = achieveThreshold // 새 목표는 리스트 맨 뒤에 배치 let existing = (try? context.fetch(FetchDescriptor())) ?? [] newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1 diff --git a/myApp/HaruDanim/Shared/Models.swift b/myApp/HaruDanim/Shared/Models.swift index ff0de5f..b477f75 100644 --- a/myApp/HaruDanim/Shared/Models.swift +++ b/myApp/HaruDanim/Shared/Models.swift @@ -229,6 +229,9 @@ final class Goal { var startDate: Date = Date() var endDate: Date? var statusRaw: String = GoalStatus.inProgress.rawValue + /// 달성 판정 기준(%). 목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'. + /// 100이면 기존과 동일하게 전체 달성이어야 함 (기본값 — CloudKit 요건). + var achieveThresholdPercent: Int = 100 /// [레거시] 접힘 상태는 기기별 로컬 설정(LocalPrefs.collapsedGoals)으로 이전. /// CloudKit 스키마 유지를 위해 남겨 두며, 최초 1회 이관 시에만 읽는다. var isCollapsed: Bool = false diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 20458fd..8f8f888 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -241,23 +241,36 @@ extension Goal { return sum / Double(counted.count) } - /// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신. - /// (다짐 전체가 마지막 주기 기준으로 달성 상태이면 달성으로 처리) - func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) { - guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return } - let allAchieved = quests.allSatisfy { quest in - QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true + /// 종료 판정용 다짐 평균 달성률 (0...1). + /// 각 다짐은 달성이면 100%, 미달성이면 현재 주기 진행률만큼 반영 ('이하 유지' 초과는 0%). + /// 오늘이 수행일이 아닌 다짐(current가 nil)은 달성으로 취급 — 기존 allSatisfy의 `?? true`와 동일. + func questAchievementRatio(math: DayMath = DayMath(), now: Date = .now) -> Double { + guard !quests.isEmpty else { return 0 } + let sum = quests.reduce(0.0) { partial, quest in + guard let result = QuestProgress(quest: quest, math: math).current(now: now) else { + return partial + 1 + } + return partial + (result.isAchieved ? 1 : min(max(result.ratio, 0), 1)) } - status = allAchieved ? .achieved : .notAchieved + return sum / Double(quests.count) } - /// 수동 종료: 하위 다짐 전체 달성 여부로 판정 (다짐 없으면 nil 반환 → UI에서 직접 질문) + /// 다짐 평균 달성률이 목표의 달성 기준(achieveThresholdPercent) 이상인지 + private func meetsAchieveThreshold(math: DayMath, now: Date) -> Bool { + questAchievementRatio(math: math, now: now) * 100 >= Double(achieveThresholdPercent) - 0.0001 + } + + /// 종료일이 지났고 다짐이 있는 목표의 달성 여부를 판정해 상태를 갱신. + /// (다짐 평균 달성률이 목표의 달성 기준 이상이면 달성으로 처리 — 기본 기준 100%) + func evaluateIfEnded(math: DayMath = DayMath(), now: Date = .now) { + guard status == .inProgress, isPastEndDate(asOf: now), !quests.isEmpty else { return } + status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved + } + + /// 수동 종료: 다짐 평균 달성률을 달성 기준과 비교해 판정 (다짐 없으면 nil 반환 → UI에서 직접 질문) func manualFinish(math: DayMath = DayMath(), now: Date = .now) -> GoalStatus? { guard !quests.isEmpty else { return nil } - let allAchieved = quests.allSatisfy { quest in - QuestProgress(quest: quest, math: math).current(now: now)?.isAchieved ?? true - } - status = allAchieved ? .achieved : .notAchieved + status = meetsAchieveThreshold(math: math, now: now) ? .achieved : .notAchieved return status } }