// // 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 // 다짐 목록 접힘 상태는 기기별 로컬 설정 (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") } } } } .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 { 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(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 { $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())) ?? [] newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1 context.insert(newGoal) } DataChange.commit(context: context) dismiss() } }