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
This commit is contained in:
parent
da740ade03
commit
c35f50fa73
Binary file not shown.
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
308
myApp/HaruDanim/IOS/Views/GoalDetailView.swift
Normal file
308
myApp/HaruDanim/IOS/Views/GoalDetailView.swift
Normal file
@ -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)
|
||||
}
|
||||
146
myApp/HaruDanim/IOS/Views/GoalTabView.swift
Normal file
146
myApp/HaruDanim/IOS/Views/GoalTabView.swift
Normal file
@ -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)
|
||||
}
|
||||
@ -25,12 +25,12 @@ struct MainTabView: View {
|
||||
Label("꼬리표", systemImage: "tag")
|
||||
}
|
||||
|
||||
Text("목표")
|
||||
GoalTabView()
|
||||
.tabItem {
|
||||
Label("목표", systemImage: "flag")
|
||||
}
|
||||
|
||||
Text("기록")
|
||||
RecordTabView()
|
||||
.tabItem {
|
||||
Label("기록", systemImage: "chart.bar")
|
||||
}
|
||||
|
||||
183
myApp/HaruDanim/IOS/Views/RecordTabView.swift
Normal file
183
myApp/HaruDanim/IOS/Views/RecordTabView.swift
Normal file
@ -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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user