- 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
184 lines
5.5 KiB
Swift
184 lines
5.5 KiB
Swift
//
|
||
// 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)
|
||
}
|