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 daf4154..c937f3d 100644 Binary files a/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/HaruDanim/HaruDanim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ 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) +}