mycode/myApp/HaruDanim/IOS/Views/GoalViews.swift
songyc macbook 2567b53b71 fix: correct daily goal progress calculation and optimize widget refresh logic
- 목표 하루 진행률은 오늘이 수행일인 다짐만 모수로 계산 (요일·날짜 지정으로
  오늘 쉬는 다짐은 분모에서 제외, QuestProgress.isScheduled 신설)
- 오늘 수행할 다짐이 하나도 없는 날은 0% 대신 100%(완료 상태)로 표기
- 주간/월간 진행률은 기존대로 전체 다짐 기준 유지
- 이벤트 기반 위젯 새로 고침: 앱 내 모든 핵심 Mutation(측정 시작/종료,
  횟수 추가, 기록 추가·수정·삭제, 행동·꼬리표·목표·다짐 CRUD, 순서 변경,
  목표 상태 변경)의 끝단에서 DataChange.commit() 호출 — 저장 즉시 확정 후
  Live Activity·위젯 타임라인·워치 스냅숏을 한 번에 동기화
- 주기적 백그라운드 새로 고침 없이 변경 시점에만 갱신해 WidgetKit
  업데이트 예산 절약 (타이머 표시는 기존 Text(style: .timer)로 시스템이
  자체 렌더링하므로 타임라인 소모 없음)
- 하루 시작 시간·주 시작 요일 설정 변경 시에도 위젯 집계 갱신
- 시뮬레이터 검증: 월·수·금 다짐이 있는 목표의 토요일 하루 진행률이
  33%가 아닌 50%(오늘 수행 다짐 2개 평균)로 표시됨 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-11 08:30:12 +09:00

646 lines
24 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.

//
// GoalViews.swift
// Haru_Danim
//
// : // + (CLAUDE.md §6.4)
//
import SwiftUI
import SwiftData
// MARK: -
struct GoalListView: View {
@Environment(\.modelContext) private var context
@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 showLimitAlert = false
@State private var isReordering = false
@State private var editMode: EditMode = .inactive
/// ( )
private var activeGoals: [Goal] { goals.filter { $0.status == .inProgress } }
/// /
private var finishedGoals: [Goal] { goals.filter { $0.status != .inProgress } }
var body: some View {
#if DEBUG
// : -goalShowFinished YES ( )
if UserDefaults.standard.bool(forKey: "goalShowFinished") {
FinishedGoalListView()
} else {
goalList
}
#else
goalList
#endif
}
private var goalList: some View {
ScrollViewReader { proxy in
goalListContent(proxy: proxy)
}
}
private func goalListContent(proxy: ScrollViewProxy) -> some View {
List {
if isReordering {
Section {
ForEach(activeGoals) { goal in
reorderRow(goal)
}
.onMove(perform: moveGoals)
} footer: {
Text("오른쪽 핸들을 끌어서 순서를 바꾼 뒤 ‘완료’를 누르세요. 순서는 저장되어 유지돼요.")
}
} else {
ForEach(activeGoals) { goal in
Section {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
GoalRow(goal: goal)
}
if !goal.isCollapsed {
ForEach(goal.sortedQuests) { quest in
QuestRow(quest: quest)
}
}
} header: {
if !goal.quests.isEmpty {
collapseHeader(goal)
}
}
}
if activeGoals.isEmpty {
ContentUnavailableView(
"진행 중인 목표가 없어요",
systemImage: "flag.checkered",
description: Text(goals.isEmpty
? "큰 목표를 세우고, 그 안에 다짐을 추가해 보세요."
: "새 목표를 세우거나, 아래에서 완료된 목표를 확인해 보세요.")
)
}
if !finishedGoals.isEmpty {
Section {
NavigationLink {
FinishedGoalListView()
} label: {
HStack {
Label("완료된 목표 보기", systemImage: "checkmark.seal")
.foregroundStyle(.primary)
Spacer()
Text("\(finishedGoals.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.id("finishedLink")
}
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("목표")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if activeGoals.count > 1 {
Button(isReordering ? String(localized: "완료") : String(localized: "순서")) {
withAnimation {
isReordering.toggle()
editMode = isReordering ? .active : .inactive
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
if !isReordering {
Button {
if !premium.canAddGoal(currentCount: goals.count) {
showLimitAlert = true
} else {
showingAdd = true
}
} label: {
Image(systemName: "plus")
}
}
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $showingAdd) {
GoalEditorView(goal: nil)
}
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
}
.onAppear {
let math = DayMath()
var statusChanged = false
for goal in goals {
let before = goal.status
goal.evaluateIfEnded(math: math)
if goal.status != before { statusChanged = true }
}
if statusChanged { DataChange.commit(context: context) }
#if DEBUG
// : -goalReorder YES , -goalScrollBottom YES
if UserDefaults.standard.bool(forKey: "goalReorder") {
isReordering = true
editMode = .active
}
if UserDefaults.standard.bool(forKey: "goalScrollBottom") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation { proxy.scrollTo("finishedLink", anchor: .bottom) }
}
}
#endif
}
}
///
private func reorderRow(_ goal: Goal) -> some View {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(goal.color, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
Text(goal.title)
.font(.subheadline.weight(.medium))
.lineLimit(1)
}
}
private func moveGoals(from source: IndexSet, to destination: Int) {
var ordered = activeGoals
ordered.move(fromOffsets: source, toOffset: destination)
for (index, goal) in ordered.enumerated() {
goal.sortOrder = index
}
DataChange.commit(context: context)
}
/// /
private func collapseHeader(_ goal: Goal) -> some View {
Button {
withAnimation(.spring(duration: 0.3)) {
goal.isCollapsed.toggle()
}
} label: {
HStack(spacing: 4) {
Text("다짐 \(goal.quests.count)")
Image(systemName: goal.isCollapsed ? "chevron.down" : "chevron.up")
.font(.caption2.weight(.semibold))
}
.font(.caption)
.foregroundStyle(AppTheme.green)
}
.buttonStyle(.plain)
.textCase(nil)
}
}
// MARK: - ( )
struct GoalRow: View {
@Environment(\.modelContext) private var context
let goal: Goal
@State private var showingConfirm = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(goal.title)
.font(.body.weight(.semibold))
Text(periodLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
statusBadge
}
if goal.needsConfirmation() {
Button {
showingConfirm = true
} label: {
Label("달성했나요? 탭해서 선택", systemImage: "questionmark.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.yellow)
}
.buttonStyle(.plain)
} else if goal.status == .inProgress, let progress = goal.dateProgress() {
VStack(alignment: .leading, spacing: 3) {
ProgressView(value: progress)
.tint(goal.color)
Text("기간 진행률 \(Format.percent(progress))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.padding(.vertical, 2)
.alert("목표 확인", isPresented: $showingConfirm) {
Button("달성했어요") {
goal.status = .achieved
DataChange.commit(context: context)
}
Button("달성하지 못했어요", role: .destructive) {
goal.status = .notAchieved
DataChange.commit(context: context)
}
Button("취소", role: .cancel) {}
} message: {
Text("\(goal.title) 목표를 달성했나요?")
}
}
private var periodLabel: String {
if let end = goal.endDate {
return "\(Format.shortDate(goal.startDate)) ~ \(Format.shortDate(end))"
}
return String(localized: "\(Format.shortDate(goal.startDate)) 시작 · 종료일 없음")
}
@ViewBuilder
private var statusBadge: some View {
let (text, color): (String, Color) = {
if goal.needsConfirmation() { return (String(localized: "확인 필요"), AppTheme.yellow) }
switch goal.status {
case .inProgress: return (String(localized: "진행 중"), AppTheme.green)
case .achieved: return (String(localized: "달성"), AppTheme.green)
case .notAchieved: return (String(localized: "미달성"), Color.secondary)
}
}()
Text(text)
.font(.caption2.weight(.bold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.15), in: Capsule())
.foregroundStyle(color)
}
}
// MARK: - (// )
struct QuestRow: View {
let quest: Quest
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: quest.targetSymbol)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 24, height: 24)
.background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
Text(quest.targetName)
.font(.subheadline.weight(.medium))
Spacer()
Text("\(quest.scheduleLabel) · \(quest.targetValueLabel) \(quest.direction.label)")
.font(.caption2)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
ForEach(StatSpan.allCases) { span in
spanGauge(span)
}
}
}
.padding(.leading, 8)
.padding(.vertical, 2)
}
private func spanGauge(_ span: StatSpan) -> some View {
let progress = QuestProgress(quest: quest)
let result = progress.spanProgress(span)
let over = quest.direction == .atMost && result.value > result.target
return VStack(alignment: .leading, spacing: 2) {
Text(span.label)
.font(.caption2)
.foregroundStyle(.secondary)
ProgressView(value: result.ratio)
.tint(over ? .red : quest.targetColor)
Text(Format.percent(result.displayRatio))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(over ? .red : .primary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - (/ )
struct FinishedGoalListView: View {
@Query(
filter: #Predicate<Goal> { $0.statusRaw != "inProgress" },
sort: \Goal.createdAt, order: .reverse
) private var goals: [Goal]
var body: some View {
List {
ForEach(goals) { goal in
Section {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
GoalRow(goal: goal)
}
}
}
if goals.isEmpty {
ContentUnavailableView(
"완료된 목표가 없어요",
systemImage: "checkmark.seal",
description: Text("달성하거나 종료한 목표가 여기에 모여요.")
)
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("완료된 목표")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
}
}
// MARK: -
struct GoalDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
private let premium = PremiumManager.shared
let goal: Goal
@State private var showingEdit = false
@State private var showingDelete = false
@State private var showingAddQuest = false
@State private var showQuestLimitAlert = false
@State private var showingManualFinish = false
@State private var askManualResult = false
@State private var editingQuest: Quest?
@State private var editMode: EditMode = .inactive
var body: some View {
List {
Section("목표") {
GoalRow(goal: goal)
}
Section {
ForEach(goal.sortedQuests) { quest in
Button {
editingQuest = quest
} label: {
QuestRow(quest: quest)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(quest)
DataChange.commit(context: context)
}
}
}
.onMove { source, destination in
var ordered = goal.sortedQuests
ordered.move(fromOffsets: source, toOffset: destination)
for (index, quest) in ordered.enumerated() {
quest.sortOrder = index
}
DataChange.commit(context: context)
}
Button {
if !premium.canAddQuest(currentCount: goal.quests.count) {
showQuestLimitAlert = true
} else {
showingAddQuest = true
}
} label: {
Label("다짐 추가", systemImage: "plus.circle")
.foregroundStyle(AppTheme.green)
}
} header: {
Text("다짐")
} footer: {
if goal.quests.isEmpty {
Text("이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.")
} else {
Text("오른쪽 위 ‘순서’를 누르면 다짐 순서를 바꿀 수 있어요.")
}
}
if goal.status == .inProgress && goal.endDate == nil {
Section {
Button("목표 수동 종료") {
showingManualFinish = true
}
.foregroundStyle(AppTheme.yellow)
} footer: {
Text("종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.")
}
}
Section {
Button("목표 삭제", role: .destructive) {
showingDelete = true
}
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle(goal.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .tabBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if !goal.quests.isEmpty {
Button(editMode == .active ? String(localized: "완료") : String(localized: "순서")) {
withAnimation {
editMode = editMode == .active ? .inactive : .active
}
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("수정") { showingEdit = true }
}
}
.environment(\.editMode, $editMode)
.sheet(isPresented: $showingEdit) {
GoalEditorView(goal: goal)
}
.sheet(isPresented: $showingAddQuest) {
QuestEditorView(goal: goal, quest: nil)
}
.sheet(item: $editingQuest) { quest in
QuestEditorView(goal: goal, quest: quest)
}
.alert("무료 사용 한도", isPresented: $showQuestLimitAlert) {
Button("확인", role: .cancel) {}
} message: {
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요.")
}
.alert("목표 수동 종료", isPresented: $showingManualFinish) {
Button("종료") {
if goal.manualFinish() == nil {
askManualResult = true
} else {
DataChange.commit(context: context)
}
}
Button("취소", role: .cancel) {}
} message: {
Text("목표를 지금 종료할까요?")
}
.alert("목표 확인", isPresented: $askManualResult) {
Button("달성했어요") {
goal.status = .achieved
DataChange.commit(context: context)
}
Button("달성하지 못했어요", role: .destructive) {
goal.status = .notAchieved
DataChange.commit(context: context)
}
Button("취소", role: .cancel) {}
} message: {
Text("다짐이 없는 목표예요. 달성했나요?")
}
.alert("목표 삭제", isPresented: $showingDelete) {
Button("삭제", role: .destructive) {
context.delete(goal)
DataChange.commit(context: context)
dismiss()
}
Button("취소", role: .cancel) {}
} message: {
Text("\(goal.title) 목표를 삭제할까요? 다짐도 함께 삭제됩니다.")
}
}
}
// MARK: - /
struct GoalEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
/// nil
let goal: Goal?
@State private var title = ""
@State private var symbolName = "flag.fill"
@State private var color: Color = Color(hex: "#2F6B4F")
@State private var startDate: Date = .now
@State private var hasEndDate = false
@State private var endDate: Date = .now
@State private var showingSymbolPicker = false
var body: some View {
NavigationStack {
Form {
Section("목표 내용") {
TextField("예: 토익 700점 이상 받기", text: $title, axis: .vertical)
}
Section("아이콘과 색") {
Button {
showingSymbolPicker = true
} label: {
HStack {
Image(systemName: symbolName)
.font(.system(size: 18))
.foregroundStyle(.white)
.frame(width: 34, height: 34)
.background(color, in: RoundedRectangle(cornerRadius: 9))
Text("아이콘 선택")
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
ColorPicker("", selection: $color, supportsOpacity: false)
}
Section("기간") {
DatePicker("시작일 (과거 가능)", selection: $startDate, displayedComponents: .date)
Toggle("종료일 지정", isOn: $hasEndDate.animation())
if hasEndDate {
DatePicker(
"종료일",
selection: $endDate,
in: startDate...,
displayedComponents: .date
)
}
}
}
.navigationTitle(goal == nil ? String(localized: "목표 추가") : String(localized: "목표 수정"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.sheet(isPresented: $showingSymbolPicker) {
SymbolPickerView(selection: $symbolName)
}
.onAppear(perform: load)
}
}
private func load() {
guard let goal else { return }
title = goal.title
symbolName = goal.symbolName
color = goal.color
startDate = goal.startDate
if let end = goal.endDate {
hasEndDate = true
endDate = end
}
}
private func save() {
let trimmed = title.trimmingCharacters(in: .whitespaces)
if let goal {
goal.title = trimmed
goal.symbolName = symbolName
goal.colorHex = color.hexString
goal.startDate = startDate
goal.endDate = hasEndDate ? endDate : nil
} else {
let newGoal = Goal(
title: trimmed,
symbolName: symbolName,
colorHex: color.hexString,
startDate: startDate,
endDate: hasEndDate ? endDate : nil
)
//
let existing = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1
context.insert(newGoal)
}
DataChange.commit(context: context)
dismiss()
}
}