mycode/myApp/HaruDanim/IOS/Views/GoalViews.swift
songyc macbook 01399afa2c chore(debug): -questShowEditor launch arg — open quest editor headlessly
다짐 편집기를 여는 검증용 런치 인자 추가 (DEBUG 전용, §14 관례).
마감 시각이 있는 다짐을 우선 열어 새 마감 UI(토글·시간 선택·footer)의
스크린샷 검증이 CLI로 가능해진다. 없으면 첫 다짐의 편집기를 연다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-15 18:26:58 +09:00

740 lines
29 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: 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 ( )
if UserDefaults.standard.bool(forKey: "questShowEditor") {
let quests = goals.flatMap(\.sortedQuests)
debugEditingQuest = quests.first { $0.deadlineMinutes >= 0 } ?? quests.first
}
#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
/// (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(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))
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)
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 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
@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
)
}
}
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%면 모든 다짐을 채워야 달성이에요. 다짐이 없는 목표는 지금처럼 직접 선택해요.")
}
}
.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
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
goal.endDate = hasEndDate ? endDate : nil
goal.achieveThresholdPercent = achieveThreshold
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 ? endDate : nil
)
newGoal.achieveThresholdPercent = achieveThreshold
//
let existing = (try? context.fetch(FetchDescriptor<Goal>())) ?? []
newGoal.sortOrder = (existing.map(\.sortOrder).max() ?? -1) + 1
context.insert(newGoal)
}
DataChange.commit(context: context)
dismiss()
}
}