mycode/myApp/HaruDanim/IOS/Views/GoalDetailView.swift
songyc macbook c35f50fa73 feat: add Goal(목표), Quest(다짐), and history tracking features
- Implement Goal(목표) and Quest(다짐) functionality
- Add a new feature for tracking and recording history
- TODO: Fix an issue in 횟수측정 Action(행동) where count and time are recorded but not reflected in the icon
2026-07-07 02:37:16 +09:00

309 lines
10 KiB
Swift

//
// GoalDetailView.swift
// HaruDanim
//
// Shows a `Goal`'s details and its `Quest`s, and hosts a sheet for
// creating new quests. Progress calculation is intentionally omitted.
//
import SwiftUI
import SwiftData
struct GoalDetailView: View {
@Environment(\.modelContext) private var modelContext
@Bindable var goal: Goal
@State private var isAddQuestPresented = false
private var dateFormatter: Date.FormatStyle {
.dateTime.year().month().day()
}
/// Quests sorted newest first for stable display order.
private var sortedQuests: [Quest] {
goal.quests.sorted { $0.createdAt > $1.createdAt }
}
var body: some View {
List {
Section("목표") {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: goal.colorHex))
.frame(width: 20, height: 20)
Text(goal.title)
Spacer()
}
LabeledContent("시작일", value: goal.startDate.formatted(dateFormatter))
LabeledContent("종료일", value: goal.endDate.formatted(dateFormatter))
}
Section("다짐") {
if sortedQuests.isEmpty {
Text("아직 다짐이 없어요.")
.foregroundStyle(.secondary)
} else {
ForEach(sortedQuests) { quest in
QuestRow(quest: quest)
}
.onDelete(perform: deleteQuests)
}
}
}
.navigationTitle(goal.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
isAddQuestPresented = true
} label: {
Label("다짐 추가", systemImage: "plus")
}
}
}
.sheet(isPresented: $isAddQuestPresented) {
AddQuestSheet(goal: goal)
}
}
private func deleteQuests(at offsets: IndexSet) {
let toDelete = offsets.map { sortedQuests[$0] }
for quest in toDelete {
modelContext.delete(quest)
}
}
}
// MARK: - Quest Row
private struct QuestRow: View {
let quest: Quest
private var targetName: String {
quest.targetAction?.name ?? quest.targetTag?.name ?? "대상 없음"
}
private var targetIcon: String {
if quest.targetAction != nil { return "bolt" }
if quest.targetTag != nil { return "tag" }
return "questionmark"
}
var body: some View {
HStack(spacing: 12) {
Image(systemName: targetIcon)
.frame(width: 24)
.foregroundStyle(Color.brandPrimary)
VStack(alignment: .leading, spacing: 4) {
Text(quest.title.isEmpty ? targetName : quest.title)
Text("\(quest.period.displayName) · \(targetName) \(QuestFormatting.value(quest.targetValue, usesTime: quest.usesTime)) \(quest.direction.displayName)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
// MARK: - Add Quest Sheet
private struct AddQuestSheet: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let goal: Goal
@Query(sort: \Action.createdAt, order: .reverse) private var actions: [Action]
@Query(sort: \Tag.createdAt, order: .reverse) private var tags: [Tag]
/// Which kind of entity the quest targets.
private enum TargetKind: String, CaseIterable, Identifiable {
case action
case tag
var id: String { rawValue }
var displayName: String { self == .action ? "행동" : "꼬리표" }
}
@State private var title = ""
@State private var targetKind: TargetKind = .action
@State private var selectedActionID: UUID?
@State private var selectedTagID: UUID?
@State private var period: QuestPeriod = .daily
@State private var direction: QuestDirection = .above
/// Measurement type used when targeting a tag (actions derive it from the action).
@State private var tagMeasureType: ActionType = .time
/// Amount entered by the user: minutes when time-based, units when count-based.
@State private var amount: Double = 0
private var selectedAction: Action? {
actions.first { $0.id == selectedActionID }
}
private var selectedTag: Tag? {
tags.first { $0.id == selectedTagID }
}
/// Whether the target is measured in time (vs. counts).
private var usesTime: Bool {
switch targetKind {
case .action: return selectedAction?.type == .time
case .tag: return tagMeasureType == .time
}
}
private var hasTarget: Bool {
switch targetKind {
case .action: return selectedAction != nil
case .tag: return selectedTag != nil
}
}
private var isValid: Bool {
hasTarget && amount > 0
}
var body: some View {
NavigationStack {
Form {
Section("이름 (선택)") {
TextField("다짐 이름", text: $title)
}
Section("대상") {
Picker("종류", selection: $targetKind) {
ForEach(TargetKind.allCases) { kind in
Text(kind.displayName).tag(kind)
}
}
.pickerStyle(.segmented)
switch targetKind {
case .action:
if actions.isEmpty {
Text("먼저 행동을 추가하세요.")
.foregroundStyle(.secondary)
} else {
Picker("행동", selection: $selectedActionID) {
Text("선택 안 함").tag(UUID?.none)
ForEach(actions) { action in
Text(action.name).tag(UUID?.some(action.id))
}
}
}
case .tag:
if tags.isEmpty {
Text("먼저 꼬리표를 추가하세요.")
.foregroundStyle(.secondary)
} else {
Picker("꼬리표", selection: $selectedTagID) {
Text("선택 안 함").tag(UUID?.none)
ForEach(tags) { tag in
Text(tag.name).tag(UUID?.some(tag.id))
}
}
Picker("측정", selection: $tagMeasureType) {
Text("시간").tag(ActionType.time)
Text("횟수").tag(ActionType.count)
}
.pickerStyle(.segmented)
}
}
}
Section("주기") {
Picker("주기", selection: $period) {
Text("매일").tag(QuestPeriod.daily)
Text("매주").tag(QuestPeriod.weekly)
Text("매월").tag(QuestPeriod.monthly)
}
.pickerStyle(.segmented)
}
Section(usesTime ? "목표 시간 (분)" : "목표 횟수") {
TextField(usesTime ? "" : "횟수", value: $amount, format: .number)
.keyboardType(.numberPad)
}
Section("방향") {
Picker("방향", selection: $direction) {
Text("이상").tag(QuestDirection.above)
Text("이하").tag(QuestDirection.below)
}
.pickerStyle(.segmented)
}
}
.navigationTitle("새 다짐")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") { save() }
.disabled(!isValid)
}
}
}
}
private func save() {
// Time targets are entered in minutes but stored in seconds.
let targetValue = usesTime ? amount * 60 : amount
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let quest = Quest(
title: trimmedTitle,
period: period,
direction: direction,
targetValue: targetValue,
usesTime: usesTime,
goal: goal,
targetAction: targetKind == .action ? selectedAction : nil,
targetTag: targetKind == .tag ? selectedTag : nil
)
modelContext.insert(quest)
dismiss()
}
}
// MARK: - Display Helpers
private enum QuestFormatting {
/// Formats a stored `targetValue` for display: minutes for time, units for count.
static func value(_ value: Double, usesTime: Bool) -> String {
if usesTime {
let minutes = Int((value / 60).rounded())
return "\(minutes)"
}
return "\(Int(value.rounded()))"
}
}
private extension QuestPeriod {
var displayName: String {
switch self {
case .daily: return "매일"
case .weekly: return "매주"
case .monthly: return "매월"
case .custom: return "사용자"
}
}
}
private extension QuestDirection {
var displayName: String {
switch self {
case .above: return "이상"
case .below: return "이하"
}
}
}
#Preview {
NavigationStack {
GoalDetailView(goal: Goal(title: "미리보기 목표"))
}
.modelContainer(for: [Goal.self, Quest.self, Action.self, Tag.self], inMemory: true)
}