mycode/myApp/Haru_Danim/IOS/Views/HistoryView.swift
songyc macbook 045f0b6f74 feat(action): enhance icon UI and add memo prompt options
- Enlarge the count number displayed on the main tab action icon
- Enhance the click feedback animation on the main tab for better visibility
- Add an option to toggle memo prompts during action creation
- Conditionally show memo prompts based on settings when stopping a timer or incrementing a count
2026-07-08 16:40:59 +09:00

680 lines
24 KiB
Swift

//
// HistoryView.swift
// Haru_Danim
//
// : / (·) / (CLAUDE.md §6.5)
//
import SwiftUI
import SwiftData
import Charts
enum HistoryMode: String, CaseIterable, Identifiable {
case list, timetable, stats
var id: String { rawValue }
var label: String {
switch self {
case .list: return "목록"
case .timetable: return "타임테이블"
case .stats: return "통계"
}
}
}
// MARK: -
struct SessionSegmentItem: Identifiable {
let session: TimeSession
let action: Action
let range: Range<Date>
var id: String {
"\(String(describing: session.persistentModelID))-\(range.lowerBound.timeIntervalSinceReferenceDate)"
}
var duration: TimeInterval { range.upperBound.timeIntervalSince(range.lowerBound) }
}
struct CountItem: Identifiable {
let entry: CountEntry
let action: Action
var id: String { String(describing: entry.persistentModelID) }
}
// MARK: -
struct HistoryView: View {
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
@State private var selectedDayKey: Date = DayMath().dayKey(for: .now)
@State private var mode: HistoryMode = {
#if DEBUG
if let raw = UserDefaults.standard.string(forKey: "historyMode"),
let mode = HistoryMode(rawValue: raw) {
return mode
}
#endif
return .list
}()
@State private var timetableWeekly = false
@State private var statSpan: StatSpan = .day
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@State private var showingCalendar = false
private var math: DayMath { DayMath() }
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
var body: some View {
VStack(spacing: 0) {
dateHeader
Picker("보기", selection: $mode) {
ForEach(HistoryMode.allCases) { m in
Text(m.label).tag(m)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
switch mode {
case .list:
listView
case .timetable:
TimetableView(
weekly: $timetableWeekly,
selectedDayKey: selectedDayKey,
segments: segments(in:),
counts: counts(in:),
math: math,
onTapSession: { editingSession = $0 },
onTapEntry: { editingEntry = $0 }
)
case .stats:
StatsView(
statSpan: $statSpan,
selectedDayKey: selectedDayKey,
segments: segments(in:),
counts: counts(in:),
math: math
)
}
}
.background(AppTheme.background)
.navigationTitle("기록")
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $editingSession) { session in
SessionEditorView(session: session)
}
.sheet(item: $editingEntry) { entry in
CountEntryEditorView(entry: entry)
}
}
// MARK: ( )
private var dateHeader: some View {
HStack {
Button {
moveDay(-1)
} label: {
Image(systemName: "chevron.left")
}
Spacer()
Button {
showingCalendar = true
} label: {
VStack(spacing: 1) {
HStack(spacing: 4) {
Text(Format.fullDate(selectedDayKey))
.font(.headline)
.foregroundStyle(.primary)
Image(systemName: "chevron.down.circle.fill")
.font(.caption)
.foregroundStyle(AppTheme.green)
}
if math.dayKey(for: .now) == selectedDayKey {
Text("오늘")
.font(.caption2)
.foregroundStyle(AppTheme.green)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showingCalendar) {
DatePicker(
"날짜 선택",
selection: calendarSelection,
displayedComponents: .date
)
.datePickerStyle(.graphical)
.frame(minWidth: 320, minHeight: 340)
.padding(8)
.presentationCompactAdaptation(.popover)
}
Spacer()
Button {
moveDay(1)
} label: {
Image(systemName: "chevron.right")
}
Button("오늘") {
selectedDayKey = math.dayKey(for: .now)
}
.font(.caption)
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
}
.padding(.horizontal)
.padding(.vertical, 10)
.tint(AppTheme.green)
}
/// ( )
private var calendarSelection: Binding<Date> {
Binding {
selectedDayKey
} set: { picked in
let cal = math.calendar
let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: picked) ?? picked
selectedDayKey = math.dayKey(for: noon)
showingCalendar = false
}
}
private func moveDay(_ delta: Int) {
selectedDayKey = math.calendar.date(byAdding: .day, value: delta, to: selectedDayKey)!
}
// MARK: ( )
private func segments(in range: Range<Date>) -> [SessionSegmentItem] {
let now = Date.now
var items: [SessionSegmentItem] = []
for session in sessions {
guard let action = session.action else { continue }
let end = session.endAt ?? now
let lower = max(session.startAt, range.lowerBound)
let upper = min(end, range.upperBound)
guard lower < upper else { continue }
items.append(SessionSegmentItem(session: session, action: action, range: lower..<upper))
}
return items.sorted { $0.range.lowerBound < $1.range.lowerBound }
}
private func counts(in range: Range<Date>) -> [CountItem] {
entries
.filter { range.contains($0.timestamp) }
.compactMap { entry in
entry.action.map { CountItem(entry: entry, action: $0) }
}
.sorted { $0.entry.timestamp < $1.entry.timestamp }
}
// MARK:
private var listView: some View {
let daySegments = segments(in: dayRange)
let dayCounts = counts(in: dayRange)
return List {
Section("시간 기록") {
if daySegments.isEmpty {
Text("시간 기록이 없어요").foregroundStyle(.secondary)
}
ForEach(daySegments) { item in
Button {
editingSession = item.session
} label: {
HStack(spacing: 10) {
Image(systemName: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 1) {
Text(item.action.name)
.font(.subheadline.weight(.medium))
Text("\(Format.time(item.range.lowerBound)) ~ \(item.session.endAt == nil ? "진행 중" : Format.time(item.range.upperBound))")
.font(.caption)
.foregroundStyle(.secondary)
if !item.session.note.isEmpty {
Label(item.session.note, systemImage: "note.text")
.font(.caption)
.foregroundStyle(AppTheme.yellow)
.lineLimit(2)
.padding(.top, 1)
}
}
Spacer()
Text(Format.durationShort(item.duration))
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
.buttonStyle(.plain)
}
}
Section("횟수 기록") {
if dayCounts.isEmpty {
Text("횟수 기록이 없어요").foregroundStyle(.secondary)
}
ForEach(dayCounts) { item in
Button {
editingEntry = item.entry
} label: {
HStack(spacing: 10) {
Image(systemName: item.action.symbolName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(item.action.color, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 1) {
Text(item.action.name)
.font(.subheadline.weight(.medium))
Text(Format.time(item.entry.timestamp))
.font(.caption)
.foregroundStyle(.secondary)
if !item.entry.note.isEmpty {
Label(item.entry.note, systemImage: "note.text")
.font(.caption)
.foregroundStyle(AppTheme.yellow)
.lineLimit(2)
.padding(.top, 1)
}
}
Spacer()
Text("+\(item.entry.amount)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
.buttonStyle(.plain)
}
}
}
.scrollContentBackground(.hidden)
}
}
// MARK: -
struct TimetableView: View {
@Binding var weekly: Bool
let selectedDayKey: Date
let segments: (Range<Date>) -> [SessionSegmentItem]
let counts: (Range<Date>) -> [CountItem]
let math: DayMath
let onTapSession: (TimeSession) -> Void
let onTapEntry: (CountEntry) -> Void
private var hourHeight: CGFloat { weekly ? 26 : 48 }
var body: some View {
VStack(spacing: 8) {
Picker("범위", selection: $weekly) {
Text("하루").tag(false)
Text("일주일").tag(true)
}
.pickerStyle(.segmented)
.padding(.horizontal)
ScrollView {
HStack(alignment: .top, spacing: 4) {
hourLabels
if weekly {
weekColumns
} else {
dayColumn(for: selectedDayKey)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal)
.padding(.bottom, 20)
}
}
}
private var startHour: Int {
let start = math.dayRange(forKey: selectedDayKey).lowerBound
return math.calendar.component(.hour, from: start)
}
private var hourLabels: some View {
VStack(alignment: .trailing, spacing: 0) {
ForEach(0..<24, id: \.self) { i in
Text(String(format: "%02d", (startHour + i) % 24))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(height: hourHeight, alignment: .top)
}
}
.frame(width: 26)
}
private var weekColumns: some View {
let weekRange = math.weekRange(containing: math.dayRange(forKey: selectedDayKey).lowerBound)
let keys = math.dayKeys(in: weekRange)
return HStack(alignment: .top, spacing: 3) {
ForEach(keys, id: \.self) { key in
VStack(spacing: 2) {
Text(Format.weekdayShort(math.calendar.component(.weekday, from: key)))
.font(.caption2)
.foregroundStyle(key == selectedDayKey ? AppTheme.green : .secondary)
dayColumn(for: key)
}
.frame(maxWidth: .infinity)
}
}
}
private func dayColumn(for key: Date) -> some View {
let range = math.dayRange(forKey: key)
let daySegments = segments(range)
let dayCounts = counts(range)
return GeometryReader { geo in
ZStack(alignment: .topLeading) {
gridLines(width: geo.size.width)
ForEach(daySegments) { item in
block(item, in: range, width: geo.size.width)
}
ForEach(Array(dayCounts.enumerated()), id: \.element.id) { index, item in
marker(item, in: range, width: geo.size.width, index: index)
}
}
}
.frame(height: hourHeight * 24)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
private func gridLines(width: CGFloat) -> some View {
VStack(spacing: 0) {
ForEach(0..<24, id: \.self) { _ in
Divider().opacity(0.4)
Spacer(minLength: 0)
}
}
.frame(width: width, height: hourHeight * 24)
}
private func yOffset(for date: Date, in range: Range<Date>) -> CGFloat {
let seconds = date.timeIntervalSince(range.lowerBound)
return CGFloat(seconds / 3600) * hourHeight
}
private func block(_ item: SessionSegmentItem, in range: Range<Date>, width: CGFloat) -> some View {
let y = yOffset(for: item.range.lowerBound, in: range)
let height = max(CGFloat(item.duration / 3600) * hourHeight, 8)
return Button {
onTapSession(item.session)
} label: {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(item.action.color.opacity(0.85))
.overlay(alignment: .topLeading) {
if !weekly && height >= 18 {
Label(item.action.name, systemImage: item.action.symbolName)
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
.padding(4)
} else if height >= 14 {
Image(systemName: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
}
}
}
.buttonStyle(.plain)
.frame(width: max(width - 4, 8), height: height)
.offset(x: 2, y: y)
}
private func marker(_ item: CountItem, in range: Range<Date>, width: CGFloat, index: Int) -> some View {
let y = yOffset(for: item.entry.timestamp, in: range)
let size: CGFloat = weekly ? 10 : 18
let x = 4 + CGFloat(index % 5) * (size + 4)
return Button {
onTapEntry(item.entry)
} label: {
ZStack {
Circle()
.fill(item.action.color)
Circle()
.strokeBorder(AppTheme.surface, lineWidth: 2)
if !weekly {
Image(systemName: item.action.symbolName)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
}
}
.frame(width: size, height: size)
}
.buttonStyle(.plain)
.offset(x: min(x, width - size), y: max(y - size / 2, 0))
}
}
// MARK: - (Swift Charts)
struct TagStat: Identifiable {
let name: String
let color: Color
let seconds: TimeInterval
let count: Int
var id: String { name }
}
struct DailyTagStat: Identifiable {
let dayKey: Date
let tagName: String
let color: Color
let hours: Double
var id: String { "\(dayKey.timeIntervalSinceReferenceDate)-\(tagName)" }
}
struct StatsView: View {
@Binding var statSpan: StatSpan
let selectedDayKey: Date
let segments: (Range<Date>) -> [SessionSegmentItem]
let counts: (Range<Date>) -> [CountItem]
let math: DayMath
private var spanRange: Range<Date> {
let anchor = math.dayRange(forKey: selectedDayKey).lowerBound
switch statSpan {
case .day: return math.dayRange(containing: anchor)
case .week: return math.weekRange(containing: anchor)
case .month: return math.monthRange(containing: anchor)
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Picker("기간", selection: $statSpan) {
ForEach(StatSpan.allCases) { span in
Text(span.label).tag(span)
}
}
.pickerStyle(.segmented)
let stats = tagStats(in: spanRange)
timeChartCard(stats)
countChartCard(stats)
weeklyTrendCard
}
.padding()
}
}
// MARK:
private func tagStats(in range: Range<Date>) -> [TagStat] {
var seconds: [String: TimeInterval] = [:]
var countsByTag: [String: Int] = [:]
var colors: [String: Color] = [:]
func tagNames(for action: Action) -> [(String, Color)] {
if action.tags.isEmpty { return [("꼬리표 없음", Color.gray)] }
return action.sortedTags.map { ($0.name, $0.color) }
}
for item in segments(range) {
for (name, color) in tagNames(for: item.action) {
seconds[name, default: 0] += item.duration
colors[name] = color
}
}
for item in counts(range) {
for (name, color) in tagNames(for: item.action) {
countsByTag[name, default: 0] += item.entry.amount
colors[name] = color
}
}
let names = Set(seconds.keys).union(countsByTag.keys)
return names
.map { name in
TagStat(
name: name,
color: colors[name] ?? .gray,
seconds: seconds[name] ?? 0,
count: countsByTag[name] ?? 0
)
}
.sorted { $0.seconds > $1.seconds }
}
// MARK:
@ViewBuilder
private func timeChartCard(_ stats: [TagStat]) -> some View {
let timeStats = stats.filter { $0.seconds > 0 }
chartCard("꼬리표별 시간 투자") {
if timeStats.isEmpty {
emptyChartText
} else {
Chart(timeStats) { stat in
BarMark(
x: .value("시간", stat.seconds / 3600),
y: .value("꼬리표", stat.name)
)
.foregroundStyle(stat.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text(Format.durationShort(stat.seconds))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel("시간(h)")
.frame(height: CGFloat(timeStats.count) * 44 + 30)
}
}
}
@ViewBuilder
private func countChartCard(_ stats: [TagStat]) -> some View {
let countStats = stats.filter { $0.count > 0 }.sorted { $0.count > $1.count }
chartCard("꼬리표별 횟수 달성") {
if countStats.isEmpty {
emptyChartText
} else {
Chart(countStats) { stat in
BarMark(
x: .value("횟수", stat.count),
y: .value("꼬리표", stat.name)
)
.foregroundStyle(stat.color)
.cornerRadius(4)
.annotation(position: .trailing) {
Text("\(stat.count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.chartXAxisLabel("횟수")
.frame(height: CGFloat(countStats.count) * 44 + 30)
}
}
}
/// 7 ( )
private var weeklyTrendCard: some View {
let data = recentDailyStats()
let names = orderedNames(from: data)
let colors = orderedColors(from: data, names: names)
return chartCard("최근 7일 시간 추이") {
if data.isEmpty {
emptyChartText
} else {
Chart(data) { item in
BarMark(
x: .value("날짜", item.dayKey, unit: .day),
y: .value("시간", item.hours)
)
.foregroundStyle(by: .value("꼬리표", item.tagName))
.cornerRadius(3)
}
.chartForegroundStyleScale(domain: names, range: colors)
.chartYAxisLabel("시간(h)")
.frame(height: 220)
}
}
}
private func recentDailyStats() -> [DailyTagStat] {
var result: [DailyTagStat] = []
for offset in (0..<7).reversed() {
guard let key = math.calendar.date(byAdding: .day, value: -offset, to: selectedDayKey) else { continue }
let range = math.dayRange(forKey: key)
for stat in tagStats(in: range) where stat.seconds > 0 {
result.append(DailyTagStat(
dayKey: key,
tagName: stat.name,
color: stat.color,
hours: stat.seconds / 3600
))
}
}
return result
}
private func orderedNames(from data: [DailyTagStat]) -> [String] {
var seen = Set<String>()
return data.map(\.tagName).filter { seen.insert($0).inserted }
}
private func orderedColors(from data: [DailyTagStat], names: [String]) -> [Color] {
names.map { name in
data.first { $0.tagName == name }?.color ?? .gray
}
}
private func chartCard(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline.weight(.semibold))
content()
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private var emptyChartText: some View {
Text("표시할 기록이 없어요")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}
}