442 lines
16 KiB
Swift
442 lines
16 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: \Goal.createdAt) private var goals: [Goal]
|
||
@AppStorage(SettingsKeys.isPremium) private var isPremium = false
|
||
|
||
@State private var showingAdd = false
|
||
@State private var showLimitAlert = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
ForEach(goals) { goal in
|
||
Section {
|
||
NavigationLink {
|
||
GoalDetailView(goal: goal)
|
||
} label: {
|
||
GoalRow(goal: goal)
|
||
}
|
||
ForEach(goal.sortedQuests) { quest in
|
||
QuestRow(quest: quest)
|
||
}
|
||
}
|
||
}
|
||
if goals.isEmpty {
|
||
ContentUnavailableView(
|
||
"목표가 없어요",
|
||
systemImage: "flag.checkered",
|
||
description: Text("큰 목표를 세우고, 그 안에 다짐을 추가해 보세요.")
|
||
)
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("목표")
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
if !isPremium && goals.count >= FreeLimits.goals {
|
||
showLimitAlert = true
|
||
} else {
|
||
showingAdd = true
|
||
}
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
}
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
.confirmationDialog(
|
||
"‘\(goal.title)’ 목표를 달성했나요?",
|
||
isPresented: $showingConfirm,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("달성했어요") { goal.status = .achieved }
|
||
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
|
||
Button("취소", role: .cancel) {}
|
||
}
|
||
}
|
||
|
||
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.target > 0 ? result.value / result.target : 0))
|
||
.font(.caption2.weight(.semibold).monospacedDigit())
|
||
.foregroundStyle(over ? .red : .primary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
// 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?
|
||
|
||
var body: some View {
|
||
List {
|
||
Section("목표") {
|
||
GoalRow(goal: goal)
|
||
}
|
||
Section {
|
||
ForEach(goal.sortedQuests) { quest in
|
||
Button {
|
||
editingQuest = quest
|
||
} label: {
|
||
QuestRow(quest: quest)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.swipeActions {
|
||
Button("삭제", role: .destructive) {
|
||
context.delete(quest)
|
||
}
|
||
}
|
||
}
|
||
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("이 목표를 이루기 위한 다짐(행동/꼬리표 + 주기 + 목표량)을 추가하세요.")
|
||
}
|
||
}
|
||
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 {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("수정") { showingEdit = true }
|
||
}
|
||
}
|
||
.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)개까지 만들 수 있어요.")
|
||
}
|
||
.confirmationDialog(
|
||
"목표를 지금 종료할까요?",
|
||
isPresented: $showingManualFinish,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("종료") {
|
||
if goal.manualFinish() == nil {
|
||
askManualResult = true
|
||
}
|
||
}
|
||
Button("취소", role: .cancel) {}
|
||
}
|
||
.confirmationDialog(
|
||
"다짐이 없는 목표예요. 달성했나요?",
|
||
isPresented: $askManualResult,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("달성했어요") { goal.status = .achieved }
|
||
Button("달성하지 못했어요", role: .destructive) { goal.status = .notAchieved }
|
||
Button("취소", role: .cancel) {}
|
||
}
|
||
.confirmationDialog(
|
||
"‘\(goal.title)’ 목표를 삭제할까요? 다짐도 함께 삭제됩니다.",
|
||
isPresented: $showingDelete,
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("삭제", role: .destructive) {
|
||
context.delete(goal)
|
||
dismiss()
|
||
}
|
||
Button("취소", role: .cancel) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
.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 {
|
||
context.insert(Goal(
|
||
title: trimmed,
|
||
symbolName: symbolName,
|
||
colorHex: color.hexString,
|
||
startDate: startDate,
|
||
endDate: hasEndDate ? endDate : nil
|
||
))
|
||
}
|
||
dismiss()
|
||
}
|
||
}
|