From c35f50fa730810b53fe3a9851307a306285ce4f1 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 7 Jul 2026 02:37:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Goal(=EB=AA=A9=ED=91=9C),=20Quest?= =?UTF-8?q?(=EB=8B=A4=EC=A7=90),=20and=20history=20tracking=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../UserInterfaceState.xcuserstate | Bin 24320 -> 30553 bytes myApp/HaruDanim/IOS/Models/Schema.swift | 27 +- .../HaruDanim/IOS/Views/GoalDetailView.swift | 308 ++++++++++++++++++ myApp/HaruDanim/IOS/Views/GoalTabView.swift | 146 +++++++++ myApp/HaruDanim/IOS/Views/MainTabView.swift | 4 +- myApp/HaruDanim/IOS/Views/RecordTabView.swift | 183 +++++++++++ 6 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Views/GoalDetailView.swift create mode 100644 myApp/HaruDanim/IOS/Views/GoalTabView.swift create mode 100644 myApp/HaruDanim/IOS/Views/RecordTabView.swift diff --git a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index daf4154e5077ff9dbeabf8d0ce850ec6a2821bc9..c937f3dd1eb07e5470a7f24b06752d1d06b4f35c 100644 GIT binary patch literal 30553 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 delta 13229 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 C2_PE) diff --git a/myApp/HaruDanim/IOS/Models/Schema.swift b/myApp/HaruDanim/IOS/Models/Schema.swift index f46590f..f0e90e0 100644 --- a/myApp/HaruDanim/IOS/Models/Schema.swift +++ b/myApp/HaruDanim/IOS/Models/Schema.swift @@ -177,6 +177,12 @@ final class Goal { @Attribute(.unique) var id: UUID var title: String var statusRaw: String + /// Hex color string (e.g. `#2F6B4F`) for goal styling. + var colorHex: String + /// Inclusive date the goal begins being tracked. + var startDate: Date + /// Inclusive date the goal is evaluated through. + var endDate: Date var createdAt: Date /// Quests composing this goal. Deleting the goal cascades to its quests. @@ -192,12 +198,18 @@ final class Goal { id: UUID = UUID(), title: String, status: GoalStatus = .inProgress, + colorHex: String = "#2F6B4F", + startDate: Date = .now, + endDate: Date = .now, createdAt: Date = .now, quests: [Quest] = [] ) { self.id = id self.title = title self.statusRaw = status.rawValue + self.colorHex = colorHex + self.startDate = startDate + self.endDate = endDate self.createdAt = createdAt self.quests = quests } @@ -213,11 +225,18 @@ final class Quest { var directionRaw: String /// The threshold this quest is measured against (seconds for time, units for count). var targetValue: Double + /// Whether `targetValue` is expressed in seconds (`true`) or discrete units (`false`). + var usesTime: Bool var createdAt: Date /// The goal this quest belongs to (inverse of `Goal.quests`). var goal: Goal? + /// The `Action` this quest measures, when it targets a single action. + var targetAction: Action? + /// The `Tag` this quest measures, when it targets a tag's aggregate. + var targetTag: Tag? + var period: QuestPeriod { get { QuestPeriod(rawValue: periodRaw) ?? .daily } set { periodRaw = newValue.rawValue } @@ -234,16 +253,22 @@ final class Quest { period: QuestPeriod = .daily, direction: QuestDirection = .above, targetValue: Double = 0, + usesTime: Bool = true, createdAt: Date = .now, - goal: Goal? = nil + goal: Goal? = nil, + targetAction: Action? = nil, + targetTag: Tag? = nil ) { self.id = id self.title = title self.periodRaw = period.rawValue self.directionRaw = direction.rawValue self.targetValue = targetValue + self.usesTime = usesTime self.createdAt = createdAt self.goal = goal + self.targetAction = targetAction + self.targetTag = targetTag } } diff --git a/myApp/HaruDanim/IOS/Views/GoalDetailView.swift b/myApp/HaruDanim/IOS/Views/GoalDetailView.swift new file mode 100644 index 0000000..7a17a93 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/GoalDetailView.swift @@ -0,0 +1,308 @@ +// +// 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) +} diff --git a/myApp/HaruDanim/IOS/Views/GoalTabView.swift b/myApp/HaruDanim/IOS/Views/GoalTabView.swift new file mode 100644 index 0000000..ad1cd75 --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/GoalTabView.swift @@ -0,0 +1,146 @@ +// +// GoalTabView.swift +// HaruDanim +// +// Lists all `Goal`s and hosts a sheet for creating new ones. +// + +import SwiftUI +import SwiftData + +struct GoalTabView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \Goal.createdAt, order: .reverse) private var goals: [Goal] + + @State private var isAddSheetPresented = false + + private var dateRangeFormatter: Date.FormatStyle { + .dateTime.year().month().day() + } + + var body: some View { + NavigationStack { + List { + ForEach(goals) { goal in + NavigationLink { + GoalDetailView(goal: goal) + } label: { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: goal.colorHex)) + .frame(width: 20, height: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(goal.title) + Text("\(goal.startDate.formatted(dateRangeFormatter)) ~ \(goal.endDate.formatted(dateRangeFormatter))") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text("다짐 \(goal.quests.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .onDelete(perform: deleteGoals) + } + .navigationTitle("목표") + .overlay { + if goals.isEmpty { + ContentUnavailableView( + "목표가 없어요", + systemImage: "flag", + description: Text("오른쪽 위 + 버튼으로 목표를 추가하세요.") + ) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + isAddSheetPresented = true + } label: { + Label("목표 추가", systemImage: "plus") + } + } + } + .sheet(isPresented: $isAddSheetPresented) { + AddGoalSheet() + } + } + } + + private func deleteGoals(at offsets: IndexSet) { + for index in offsets { + modelContext.delete(goals[index]) + } + } +} + +// MARK: - Add Goal Sheet + +private struct AddGoalSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var title = "" + @State private var color = Color(hex: "#2F6B4F") + @State private var startDate = Date.now + @State private var endDate = Date.now + + private var trimmedTitle: String { + title.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isValid: Bool { + !trimmedTitle.isEmpty && endDate >= startDate + } + + var body: some View { + NavigationStack { + Form { + Section("이름") { + TextField("목표 이름", text: $title) + } + + Section("색상") { + ColorPicker("색상 선택", selection: $color, supportsOpacity: false) + } + + Section("기간") { + DatePicker("시작일", selection: $startDate, displayedComponents: .date) + DatePicker("종료일", selection: $endDate, in: startDate..., displayedComponents: .date) + } + } + .navigationTitle("새 목표") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { save() } + .disabled(!isValid) + } + } + } + } + + private func save() { + let goal = Goal( + title: trimmedTitle, + colorHex: color.toHex(), + startDate: startDate, + endDate: endDate + ) + modelContext.insert(goal) + dismiss() + } +} + +#Preview { + GoalTabView() + .modelContainer(for: [Goal.self, Quest.self, Action.self, Tag.self], inMemory: true) +} diff --git a/myApp/HaruDanim/IOS/Views/MainTabView.swift b/myApp/HaruDanim/IOS/Views/MainTabView.swift index c67512d..58a8246 100644 --- a/myApp/HaruDanim/IOS/Views/MainTabView.swift +++ b/myApp/HaruDanim/IOS/Views/MainTabView.swift @@ -25,12 +25,12 @@ struct MainTabView: View { Label("꼬리표", systemImage: "tag") } - Text("목표") + GoalTabView() .tabItem { Label("목표", systemImage: "flag") } - Text("기록") + RecordTabView() .tabItem { Label("기록", systemImage: "chart.bar") } diff --git a/myApp/HaruDanim/IOS/Views/RecordTabView.swift b/myApp/HaruDanim/IOS/Views/RecordTabView.swift new file mode 100644 index 0000000..c9f581c --- /dev/null +++ b/myApp/HaruDanim/IOS/Views/RecordTabView.swift @@ -0,0 +1,183 @@ +// +// RecordTabView.swift +// HaruDanim +// +// Shows the tracking records (`TimeSession`s and `CountEntry`s) that occurred +// on a user-selected date, as a single chronological list. +// + +import SwiftUI +import SwiftData + +struct RecordTabView: View { + @Query(sort: \TimeSession.startTime, order: .reverse) private var sessions: [TimeSession] + @Query(sort: \CountEntry.timestamp, order: .reverse) private var counts: [CountEntry] + + /// The day whose records are shown. Defaults to today. + @State private var selectedDate: Date = .now + + /// Records that fall on `selectedDate`, merged and sorted newest first. + private var recordsForDay: [RecordItem] { + let calendar = Calendar.current + + let sessionItems = sessions + .filter { calendar.isDate($0.startTime, inSameDayAs: selectedDate) } + .map { RecordItem.session($0) } + + let countItems = counts + .filter { calendar.isDate($0.timestamp, inSameDayAs: selectedDate) } + .map { RecordItem.count($0) } + + return (sessionItems + countItems).sorted { $0.sortTime > $1.sortTime } + } + + var body: some View { + NavigationStack { + List { + Section { + DatePicker( + "날짜", + selection: $selectedDate, + displayedComponents: .date + ) + } + + Section("기록") { + if recordsForDay.isEmpty { + Text("이 날의 기록이 없어요.") + .foregroundStyle(.secondary) + } else { + ForEach(recordsForDay) { item in + switch item { + case .session(let session): + TimeSessionRow(session: session) + case .count(let entry): + CountEntryRow(entry: entry) + } + } + } + } + } + .navigationTitle("기록") + } + } +} + +// MARK: - Record Item + +/// A single tracking record on a given day, either time- or count-based. +private enum RecordItem: Identifiable { + case session(TimeSession) + case count(CountEntry) + + var id: UUID { + switch self { + case .session(let session): return session.id + case .count(let entry): return entry.id + } + } + + /// The time used to order records within a day. + var sortTime: Date { + switch self { + case .session(let session): return session.startTime + case .count(let entry): return entry.timestamp + } + } +} + +// MARK: - Rows + +private struct TimeSessionRow: View { + let session: TimeSession + + var body: some View { + HStack(spacing: 12) { + TagColorDot(colorHex: session.action?.tags.first?.colorHex) + + VStack(alignment: .leading, spacing: 4) { + Text(session.action?.name ?? "알 수 없는 행동") + + HStack(spacing: 4) { + Text(session.startTime.formatted(date: .omitted, time: .shortened)) + Text("–") + if let endTime = session.endTime { + Text(endTime.formatted(date: .omitted, time: .shortened)) + } else { + Text("진행 중") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(durationText) + .font(.caption.monospacedDigit()) + .foregroundStyle(Color.brandPrimary) + } + } + + /// Formatted elapsed time, or a running indicator when the session is open. + private var durationText: String { + guard let endTime = session.endTime else { return "진행 중" } + return RecordFormatting.duration(from: session.startTime, to: endTime) + } +} + +private struct CountEntryRow: View { + let entry: CountEntry + + var body: some View { + HStack(spacing: 12) { + TagColorDot(colorHex: entry.action?.tags.first?.colorHex) + + VStack(alignment: .leading, spacing: 4) { + Text(entry.action?.name ?? "알 수 없는 행동") + + Text(entry.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text("\(entry.amount)회") + .font(.caption.monospacedDigit()) + .foregroundStyle(Color.brandPrimary) + } + } +} + +// MARK: - Tag Color Dot + +private struct TagColorDot: View { + let colorHex: String? + + var body: some View { + Circle() + .fill(colorHex.map { Color(hex: $0) } ?? Color.secondary.opacity(0.3)) + .frame(width: 16, height: 16) + } +} + +// MARK: - Display Helpers + +private enum RecordFormatting { + /// Formats an interval as `H시간 M분` (hours dropped when zero, at least `0분`). + static func duration(from start: Date, to end: Date) -> String { + let totalSeconds = max(0, Int(end.timeIntervalSince(start))) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + if hours > 0 { + return "\(hours)시간 \(minutes)분" + } + return "\(minutes)분" + } +} + +#Preview { + RecordTabView() + .modelContainer(for: [Action.self, Tag.self, TimeSession.self, CountEntry.self], inMemory: true) +}