- Implement drag-and-drop functionality for users to reorder the active goals list - Ensure the custom order of goals is persistently saved and loaded - Filter out completed goals from the main active list to reduce visual clutter - Add a 'View Completed Goals' button to navigate to a dedicated screen for finished items
623 lines
23 KiB
Swift
623 lines
23 KiB
Swift
//
|
||
// 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]
|
||
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
|
||
|
||
@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 ? "완료" : "순서") {
|
||
withAnimation {
|
||
isReordering.toggle()
|
||
editMode = isReordering ? .active : .inactive
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
if !isReordering {
|
||
Button {
|
||
if !isPremium && goals.count >= FreeLimits.goals {
|
||
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()
|
||
for goal in goals {
|
||
goal.evaluateIfEnded(math: math)
|
||
}
|
||
#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
|
||
}
|
||
}
|
||
|
||
/// 다짐 접기/펼치기 토글 헤더
|
||
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 }
|
||
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
|
||
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 "\(Format.shortDate(goal.startDate)) 시작 · 종료일 없음"
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var statusBadge: some View {
|
||
let (text, color): (String, Color) = {
|
||
if goal.needsConfirmation() { return ("확인 필요", AppTheme.yellow) }
|
||
switch goal.status {
|
||
case .inProgress: return ("진행 중", AppTheme.green)
|
||
case .achieved: return ("달성", AppTheme.green)
|
||
case .notAchieved: return ("미달성", 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
|
||
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
.onMove { source, destination in
|
||
var ordered = goal.sortedQuests
|
||
ordered.move(fromOffsets: source, toOffset: destination)
|
||
for (index, quest) in ordered.enumerated() {
|
||
quest.sortOrder = index
|
||
}
|
||
}
|
||
Button {
|
||
if !isPremium && goal.quests.count >= FreeLimits.questsPerGoal {
|
||
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 ? "완료" : "순서") {
|
||
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
|
||
}
|
||
}
|
||
Button("취소", role: .cancel) {}
|
||
} message: {
|
||
Text("목표를 지금 종료할까요?")
|
||
}
|
||
.alert("목표 확인", isPresented: $askManualResult) {
|
||
Button("달성했어요") { goal.status = .achieved }
|
||
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
|
||
Button("취소", role: .cancel) {}
|
||
} message: {
|
||
Text("다짐이 없는 목표예요. 달성했나요?")
|
||
}
|
||
.alert("목표 삭제", isPresented: $showingDelete) {
|
||
Button("삭제", role: .destructive) {
|
||
context.delete(goal)
|
||
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 ? "목표 추가" : "목표 수정")
|
||
.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)
|
||
}
|
||
dismiss()
|
||
}
|
||
}
|