// // 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: 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 @State private var editMode: EditMode = .inactive #if DEBUG /// 검증용 (-questShowEditor): 다짐 편집기를 바로 표시 (마감 시각 다짐 우선, 없으면 첫 다짐) @State private var debugEditingQuest: Quest? #endif // 다짐 목록 접힘 상태는 기기별 로컬 설정 (CloudKit 동기화 제외, LocalPrefs 참고) @AppStorage(LocalPrefsKeys.collapsedGoals, store: AppGroup.defaults) private var collapsedGoalsRaw = "" /// 진행 중(확인 필요 포함) 목표만 기본 목록에 표시 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 !LocalPrefs.contains(goal.uuid, in: collapsedGoalsRaw) { 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") } .accessibilityLabel(Text("목표 추가")) } } } .environment(\.editMode, $editMode) .sheet(isPresented: $showingAdd) { GoalEditorView(goal: nil) } #if DEBUG .sheet(item: $debugEditingQuest) { quest in if let goal = quest.goal { QuestEditorView(goal: goal, quest: quest) } } #endif .alert("무료 사용 한도", isPresented: $showLimitAlert) { Button("프리미엄 알아보기") { showingPremiumSheet = true } Button("확인", role: .cancel) {} } message: { Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.") } .sheet(isPresented: $showingPremiumSheet) { PremiumSheetView() } .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) } } } // 검증용: -questShowEditor YES → 다짐 편집기 바로 표시 (마감 시각 다짐 우선). // -questShowEditorHealth YES를 함께 주면 건강 다짐 우선 (1.5 편집 UI 검증) if UserDefaults.standard.bool(forKey: "questShowEditor") { let quests = goals.flatMap(\.sortedQuests) if UserDefaults.standard.bool(forKey: "questShowEditorHealth") { debugEditingQuest = quests.first { $0.isHealthQuest } ?? quests.first } else { debugEditingQuest = quests.first { $0.deadlineMinutes >= 0 } ?? quests.first } } #endif } } /// 순서 변경 모드에서 보여줄 간략한 목표 행 private func reorderRow(_ goal: Goal) -> some View { HStack(spacing: 10) { Image(safeSymbol: 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 { let collapsed = LocalPrefs.contains(goal.uuid, in: collapsedGoalsRaw) return Button { withAnimation(.spring(duration: 0.3)) { collapsedGoalsRaw = LocalPrefs.toggling(goal.uuid, in: collapsedGoalsRaw) } } label: { HStack(spacing: 4) { Text("다짐 \(goal.quests.count)개") Image(systemName: collapsed ? "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(safeSymbol: 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 /// 연속 달성 (1 이상일 때만 표기 — 끊기면 배지가 사라진다) private var streakLabel: String? { guard let streak = QuestProgress(quest: quest).streak(), streak.count > 0 else { return nil } return streak.label } var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Image(safeSymbol: 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)) if let streakLabel { HStack(spacing: 2) { Image(systemName: "flame.fill") .font(.system(size: 9, weight: .bold)) Text(streakLabel) .font(.caption2.weight(.semibold)) } .foregroundStyle(AppTheme.yellow) .lineLimit(1) .layoutPriority(1) } 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) // 건강 데이터 없는 기기(맥 등)의 건강 다짐(1.5 §8ⓒ)은 어느 span에도 값이 없음 — // 수행일 아님과 같은 시각 문법으로 처리하고 문구만 '데이터 없음' let healthUnavailable = quest.isHealthQuest && !HealthSupport.deviceAvailable // 오늘이 수행일이 아니면(요일·날짜 다짐의 쉬는 날, 기간 밖) 하루 게이지는 수치 대신 상태 표기 — // 쉬는 날의 기록은 주/월 집계에도 안 잡히는 값이라 퍼센트로 보여 주면 "오늘 채웠다"는 오해를 유발 let restDay = healthUnavailable || (span == .day && !progress.isScheduled(on: .now)) // 주기 몫을 이미 채운 주/월 다짐의 하루 게이지는 0% 대신 완료 상태 — // 빈 게이지가 "안 했음"으로 오독되는 것을 막기 위해 가득 채우고 문구로 이유를 설명 (§4.2) let fulfilled = span == .day && !restDay && progress.isPeriodFulfilled() let result = progress.spanProgress(span) let over = !restDay && quest.direction == .atMost && result.value > result.target return VStack(alignment: .leading, spacing: 2) { Text(span.label) .font(.caption2) .foregroundStyle(.secondary) ProgressView(value: restDay ? 0 : (fulfilled ? 1 : result.ratio)) .tint(over ? .red : quest.targetColor) .opacity(restDay ? 0.4 : 1) if restDay { Text(healthUnavailable ? String(localized: "데이터 없음") : String(localized: "수행일 아님")) .font(.caption2) .foregroundStyle(.tertiary) } else if fulfilled { HStack(spacing: 3) { Image(systemName: "checkmark.circle.fill") .font(.caption2) .foregroundStyle(AppTheme.green) Text(progress.periodFulfilledLabel) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) } } else { Text(Format.percent(result.displayRatio)) .font(.caption2.weight(.semibold).monospacedDigit()) .foregroundStyle(over ? .red : .primary) } } .frame(maxWidth: .infinity) } } // MARK: - 완료된 목표 (달성/미달성으로 끝난 목표만 모아서 표시) struct FinishedGoalListView: View { @Query( filter: #Predicate { $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 showingPremiumSheet = 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: { if goal.achieveThresholdPercent < 100 { Text("종료 시 다짐 평균 달성률이 \(goal.achieveThresholdPercent)% 이상이면 달성으로 판정돼요.") } else { 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("프리미엄 알아보기") { showingPremiumSheet = true } Button("확인", role: .cancel) {} } message: { Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.") } .sheet(isPresented: $showingPremiumSheet) { PremiumSheetView() } .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 achieveThreshold = 100 /// 워치 컴플리케이션 목록 노출 여부 — 기기 로컬 설정(LocalPrefs), 기본 꺼짐 @State private var showsOnWatch = false @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(safeSymbol: 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 ) } } 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%면 모든 다짐을 채워야 달성이에요. 다짐이 없는 목표는 지금처럼 직접 선택해요.") } // 워치는 iPhone과 페어링되고 이 설정은 기기 로컬이라 iPad에서는 의미가 없다 if !DeviceLayout.isPad { Section { Toggle(isOn: $showsOnWatch) { Label { Text("애플워치에서 보기") } icon: { Image(systemName: "applewatch") .foregroundStyle(AppTheme.green) } } } header: { Text("애플워치") } footer: { Text("켜면 이 목표와 소속 다짐들의 진행률이 애플워치 컴플리케이션 목록에 나타나요. 완료된 목표는 목록에서 자동으로 사라져요. 이 설정은 워치와 연결된 이 아이폰에만 저장돼요.") } } } .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 achieveThreshold = goal.achieveThresholdPercent showsOnWatch = LocalPrefs.showsOnWatch(goal.uuid) if let end = goal.endDate { hasEndDate = true endDate = end } } private func save() { let trimmed = title.trimmingCharacters(in: .whitespaces) if let goal { let previousEndDate = goal.endDate goal.title = trimmed goal.symbolName = symbolName goal.colorHex = color.hexString goal.startDate = startDate // 같은 편집 세션에서 시작일을 종료일 뒤로 옮기면 DatePicker의 in: 제약은 표시만 // 걸릴 뿐 endDate 상태값이 안 바뀐다 — 종료<시작으로 저장되지 않게 정규화 goal.endDate = hasEndDate ? max(endDate, startDate) : nil goal.achieveThresholdPercent = achieveThreshold // 워치 노출은 기기 로컬 설정 — CloudKit 모델에 쓰지 않는다 (LocalPrefs 참고) LocalPrefs.setShowsOnWatch(goal.uuid, enabled: showsOnWatch) if goal.status == .inProgress { // 종료일을 이미 지난 날짜로 고쳤다면 목록에 돌아가기 전에 즉시 판정 // (판정 기준 시점은 종료일의 하루 끝 — evaluateIfEnded 참고) goal.evaluateIfEnded() } else if previousEndDate != goal.endDate { // 완료된 목표의 종료일을 직접 바꿨다면 다시 판정 대상으로: // 미래/없음으로 옮기면 '진행 중'으로 재개되고, 다른 과거 날짜면 그 시점 기준으로 재판정 goal.status = .inProgress goal.evaluateIfEnded() } } else { let newGoal = Goal( title: trimmed, symbolName: symbolName, colorHex: color.hexString, startDate: startDate, endDate: hasEndDate ? max(endDate, startDate) : nil ) newGoal.achieveThresholdPercent = achieveThreshold // 새 목표는 리스트 맨 뒤에 배치 let existing = (try? context.fetch(FetchDescriptor())) ?? [] newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1 context.insert(newGoal) LocalPrefs.setShowsOnWatch(newGoal.uuid, enabled: showsOnWatch) } DataChange.commit(context: context) dismiss() } }