402 lines
13 KiB
Swift
402 lines
13 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
|
|
|
|
/// App settings drive the logical day boundary used for progress windows.
|
|
@Query private var appSettings: [AppSettings]
|
|
|
|
@State private var viewModel = GoalViewModel()
|
|
@State private var isAddQuestPresented = false
|
|
|
|
/// Hour at which a logical day begins (defaults to midnight).
|
|
private var dayStartHour: Int {
|
|
appSettings.first?.dayStartHour ?? 0
|
|
}
|
|
|
|
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, viewModel: viewModel, dayStartHour: dayStartHour)
|
|
}
|
|
.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
|
|
let viewModel: GoalViewModel
|
|
let dayStartHour: Int
|
|
|
|
private var progress: QuestProgress {
|
|
viewModel.progress(for: quest, dayStartHour: dayStartHour)
|
|
}
|
|
|
|
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 {
|
|
let progress = self.progress
|
|
let evaluation = progress.evaluation
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
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()
|
|
|
|
Image(systemName: evaluation.iconName)
|
|
.foregroundStyle(evaluation.tint)
|
|
.imageScale(.large)
|
|
}
|
|
|
|
if evaluation == .unsupported {
|
|
Text("이 주기는 아직 진행률을 계산하지 않아요.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ProgressView(value: progress.fraction)
|
|
.tint(evaluation.tint)
|
|
|
|
HStack {
|
|
Text("\(QuestFormatting.amount(progress.current, usesTime: progress.usesTime)) / \(QuestFormatting.amount(progress.target, usesTime: progress.usesTime))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
Spacer()
|
|
Text(evaluation.label)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(evaluation.tint)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// 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()))회"
|
|
}
|
|
|
|
/// Formats an accumulated amount for progress display. Time is shown as
|
|
/// hours + minutes once it reaches an hour, otherwise minutes; counts as 회.
|
|
static func amount(_ value: Double, usesTime: Bool) -> String {
|
|
guard usesTime else { return "\(Int(value.rounded()))회" }
|
|
|
|
let totalMinutes = Int((value / 60).rounded())
|
|
if totalMinutes >= 60 {
|
|
let hours = totalMinutes / 60
|
|
let minutes = totalMinutes % 60
|
|
return minutes == 0 ? "\(hours)시간" : "\(hours)시간 \(minutes)분"
|
|
}
|
|
return "\(totalMinutes)분"
|
|
}
|
|
}
|
|
|
|
private extension QuestEvaluation {
|
|
/// Accent yellow (`#D9A621`) used for in-progress states.
|
|
private static let accent = Color(hex: "#D9A621")
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .achieved: return "달성"
|
|
case .inProgress: return "진행 중"
|
|
case .onTrack: return "유지 중"
|
|
case .failed: return "초과"
|
|
case .unsupported: return ""
|
|
}
|
|
}
|
|
|
|
var iconName: String {
|
|
switch self {
|
|
case .achieved: return "checkmark.circle.fill"
|
|
case .inProgress: return "hourglass"
|
|
case .onTrack: return "checkmark.shield.fill"
|
|
case .failed: return "exclamationmark.triangle.fill"
|
|
case .unsupported: return "questionmark.circle"
|
|
}
|
|
}
|
|
|
|
var tint: Color {
|
|
switch self {
|
|
case .achieved, .onTrack: return .brandPrimary
|
|
case .inProgress: return Self.accent
|
|
case .failed: return .red
|
|
case .unsupported: return .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|