feat: enhance records with charts/filters and update main tab UI

[New Features]
- feat(records): add line charts for weekly/monthly trends and averages
- feat(records): implement multi-select filters for specific tags and actions
- feat(main): add 'view records' option to the action icon long-press menu
- feat(main): display goal progress widget with customizable settings

[UI/UX & Bug Fixes]
- fix(ui): expand touchable areas globally, especially in record lists
- chore(main): rename main tab top title from '하루다님' to '모음'
- feat(nav): route to goal detail screen when tapping the goal widget
This commit is contained in:
songyc macbook 2026-07-09 02:22:04 +09:00
parent 045f0b6f74
commit afb7435656
10 changed files with 507 additions and 6 deletions

View File

@ -82,7 +82,7 @@ enum AppTab: String, CaseIterable, Identifiable {
var label: String {
switch self {
case .main: return "메인"
case .main: return "모음"
case .action: return "행동"
case .tag: return "꼬리표"
case .goal: return "목표"

View File

@ -96,6 +96,7 @@ enum DebugSeed {
startDate: cal.date(byAdding: .day, value: -10, to: now)!,
endDate: cal.date(byAdding: .day, value: 30, to: now)!
)
toeic.showsOnMain = true
context.insert(toeic)
let englishQuest = Quest(goal: toeic)
englishQuest.targetAction = english

View File

@ -188,6 +188,8 @@ final class Goal {
var statusRaw: String = GoalStatus.inProgress.rawValue
///
var isCollapsed: Bool = false
/// ( , 1)
var showsOnMain: Bool = false
var createdAt: Date = Date()
@Relationship(deleteRule: .cascade, inverse: \Quest.goal)

View File

@ -119,6 +119,12 @@
},
"기록 직접 추가" : {
},
"기록 필터" : {
},
"기록 확인" : {
},
"기록이 없어요" : {
@ -155,6 +161,9 @@
},
"꼬리표가 없어요" : {
},
"꼬리표로 보기" : {
},
"끝" : {
@ -170,6 +179,9 @@
},
"누적 기록" : {
},
"누적 횟수" : {
},
"다이나믹 아일랜드 · 잠금화면" : {
@ -250,6 +262,15 @@
},
"몇째 주" : {
},
"모음" : {
},
"모음 탭" : {
},
"목록·타임테이블·통계 모두에 필터가 적용돼요." : {
},
"목표" : {
@ -265,6 +286,12 @@
},
"목표 수정" : {
},
"목표 진행 현황" : {
},
"목표 진행 현황 표시" : {
},
"목표 추가" : {
@ -347,6 +374,12 @@
},
"선택" : {
},
"선택한 꼬리표가 붙은 행동의 기록만 표시돼요." : {
},
"선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요." : {
},
"선택한 탭(최대 %lld개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요." : {
@ -551,6 +584,15 @@
},
"특정 시각 지정" : {
},
"평균" : {
},
"평균 %@" : {
},
"표시 안 함" : {
},
"표시할 기록이 없어요" : {
@ -622,6 +664,9 @@
},
"행동 탭에서 추적할 행동을 추가해 보세요." : {
},
"행동으로 보기 (여러 개 선택 가능)" : {
},
"현재 진행 중" : {

View File

@ -255,6 +255,8 @@ struct GoalDetailView: View {
editingQuest = quest
} label: {
QuestRow(quest: quest)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {

View File

@ -49,6 +49,8 @@ struct CountItem: Identifiable {
struct HistoryView: View {
@Query private var sessions: [TimeSession]
@Query private var entries: [CountEntry]
@Query(sort: \Tag.sortOrder) private var tags: [Tag]
@Query(sort: \Action.sortOrder) private var allActions: [Action]
@State private var selectedDayKey: Date = DayMath().dayKey(for: .now)
@State private var mode: HistoryMode = {
@ -61,10 +63,21 @@ struct HistoryView: View {
return .list
}()
@State private var timetableWeekly = false
@State private var statSpan: StatSpan = .day
@State private var statSpan: StatSpan = {
#if DEBUG
if let raw = UserDefaults.standard.string(forKey: "statSpan"),
let span = StatSpan(rawValue: raw) {
return span
}
#endif
return .day
}()
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@State private var showingCalendar = false
@State private var showingFilter = false
@State private var filterTag: Tag?
@State private var filterActionIDs: Set<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
private var dayRange: Range<Date> { math.dayRange(forKey: selectedDayKey) }
@ -81,6 +94,10 @@ struct HistoryView: View {
.padding(.horizontal)
.padding(.bottom, 8)
if isFiltering {
filterChip
}
switch mode {
case .list:
listView
@ -107,12 +124,93 @@ struct HistoryView: View {
.background(AppTheme.background)
.navigationTitle("기록")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingFilter = true
} label: {
Image(systemName: isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
}
}
.sheet(item: $editingSession) { session in
SessionEditorView(session: session)
}
.sheet(item: $editingEntry) { entry in
CountEntryEditorView(entry: entry)
}
.sheet(isPresented: $showingFilter) {
HistoryFilterSheet(
tags: tags,
actions: allActions,
filterTag: $filterTag,
filterActionIDs: $filterActionIDs
)
}
}
// MARK: ( 1 )
private var isFiltering: Bool {
filterTag != nil || !filterActionIDs.isEmpty
}
private func passesFilter(_ action: Action) -> Bool {
if !filterActionIDs.isEmpty {
return filterActionIDs.contains(action.persistentModelID)
}
if let filterTag {
return action.tags.contains(filterTag)
}
return true
}
private var filterLabel: String {
if !filterActionIDs.isEmpty {
let names = allActions
.filter { filterActionIDs.contains($0.persistentModelID) }
.map(\.name)
return names.count <= 2
? names.joined(separator: ", ")
: "\(names.prefix(2).joined(separator: ", "))\(names.count - 2)"
}
if let filterTag {
return "#\(filterTag.name)"
}
return ""
}
private var filterChip: some View {
HStack(spacing: 6) {
Image(systemName: "line.3.horizontal.decrease")
.font(.caption2.weight(.semibold))
Text(filterLabel)
.font(.caption.weight(.semibold))
.lineLimit(1)
Button {
withAnimation {
filterTag = nil
filterActionIDs = []
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.foregroundStyle(AppTheme.green)
.padding(.leading, 10)
.padding(.trailing, 2)
.padding(.vertical, 4)
.background(AppTheme.green.opacity(0.12), in: Capsule())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.padding(.bottom, 6)
}
// MARK: ( )
@ -123,6 +221,8 @@ struct HistoryView: View {
moveDay(-1)
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
Spacer()
Button {
@ -162,6 +262,8 @@ struct HistoryView: View {
moveDay(1)
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
Button("오늘") {
selectedDayKey = math.dayKey(for: .now)
@ -197,7 +299,7 @@ struct HistoryView: View {
let now = Date.now
var items: [SessionSegmentItem] = []
for session in sessions {
guard let action = session.action else { continue }
guard let action = session.action, passesFilter(action) else { continue }
let end = session.endAt ?? now
let lower = max(session.startAt, range.lowerBound)
let upper = min(end, range.upperBound)
@ -213,6 +315,7 @@ struct HistoryView: View {
.compactMap { entry in
entry.action.map { CountItem(entry: entry, action: $0) }
}
.filter { passesFilter($0.action) }
.sorted { $0.entry.timestamp < $1.entry.timestamp }
}
@ -255,6 +358,8 @@ struct HistoryView: View {
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ -292,6 +397,8 @@ struct HistoryView: View {
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ -508,6 +615,11 @@ struct StatsView: View {
let stats = tagStats(in: spanRange)
timeChartCard(stats)
countChartCard(stats)
if statSpan != .day {
let points = trendPoints()
timeTrendCard(points)
countTrendCard(points)
}
weeklyTrendCard
}
.padding()
@ -606,6 +718,122 @@ struct StatsView: View {
}
}
// MARK: (/, + )
private struct TrendPoint: Identifiable {
let dayKey: Date
let hours: Double
let count: Int
let cumulativeCount: Int
var id: Date { dayKey }
}
///
private func trendPoints() -> [TrendPoint] {
var cumulative = 0
return math.dayKeys(in: spanRange).map { key in
let range = math.dayRange(forKey: key)
let seconds = segments(range).reduce(0.0) { $0 + $1.duration }
let count = counts(range).reduce(0) { $0 + $1.entry.amount }
cumulative += count
return TrendPoint(dayKey: key, hours: seconds / 3600, count: count, cumulativeCount: cumulative)
}
}
/// " ()" /
private func elapsedDayCount(_ points: [TrendPoint]) -> Int {
max(points.filter { math.dayRange(forKey: $0.dayKey).lowerBound <= .now }.count, 1)
}
@ViewBuilder
private func timeTrendCard(_ points: [TrendPoint]) -> some View {
let totalHours = points.reduce(0.0) { $0 + $1.hours }
let avgHours = totalHours / Double(elapsedDayCount(points))
chartCard("시간 추세 (하루 합산)") {
if totalHours == 0 {
emptyChartText
} else {
Chart {
ForEach(points) { point in
LineMark(
x: .value("날짜", point.dayKey, unit: .day),
y: .value("시간", point.hours)
)
.foregroundStyle(AppTheme.green)
.interpolationMethod(.monotone)
.symbol(.circle)
}
RuleMark(y: .value("평균", avgHours))
.foregroundStyle(AppTheme.yellow)
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: [5, 4]))
.annotation(position: .top, alignment: .trailing) {
Text("평균 \(Format.durationShort(avgHours * 3600))")
.font(.caption2.weight(.semibold))
.foregroundStyle(AppTheme.yellow)
}
}
.chartYAxisLabel("시간(h)")
.frame(height: 180)
averageRow(
total: Format.durationShort(totalHours * 3600),
average: Format.durationShort(avgHours * 3600)
)
}
}
}
@ViewBuilder
private func countTrendCard(_ points: [TrendPoint]) -> some View {
let totalCount = points.last?.cumulativeCount ?? 0
let avgCount = Double(totalCount) / Double(elapsedDayCount(points))
chartCard("횟수 추세 (누적)") {
if totalCount == 0 {
emptyChartText
} else {
Chart {
ForEach(points) { point in
LineMark(
x: .value("날짜", point.dayKey, unit: .day),
y: .value("누적 횟수", point.cumulativeCount)
)
.foregroundStyle(AppTheme.green)
.interpolationMethod(.monotone)
.symbol(.circle)
}
}
.chartYAxisLabel("누적 횟수")
.frame(height: 180)
averageRow(
total: "\(totalCount)",
average: String(format: "%.1f회", avgCount)
)
}
}
}
/// : ·
private func averageRow(total: String, average: String) -> some View {
HStack(spacing: 0) {
summaryCell(label: "\(statSpan.label) 합계", value: total)
Divider().frame(height: 28)
summaryCell(label: "하루 평균", value: average)
}
.padding(.top, 4)
}
private func summaryCell(label: String, value: String) -> some View {
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(AppTheme.green)
}
.frame(maxWidth: .infinity)
}
/// 7 ( )
private var weeklyTrendCard: some View {
let data = recentDailyStats()
@ -677,3 +905,107 @@ struct StatsView: View {
.padding(.vertical, 24)
}
}
// MARK: - ( 1 )
struct HistoryFilterSheet: View {
@Environment(\.dismiss) private var dismiss
let tags: [Tag]
let actions: [Action]
@Binding var filterTag: Tag?
@Binding var filterActionIDs: Set<PersistentIdentifier>
var body: some View {
NavigationStack {
List {
Section {
selectableRow(
title: "전체 보기",
symbol: "square.grid.2x2",
color: AppTheme.green,
selected: filterTag == nil && filterActionIDs.isEmpty
) {
filterTag = nil
filterActionIDs = []
}
}
Section {
ForEach(tags) { tag in
selectableRow(
title: tag.name,
symbol: "tag.fill",
color: tag.color,
selected: filterTag == tag
) {
filterTag = tag
filterActionIDs = []
}
}
} header: {
Text("꼬리표로 보기")
} footer: {
Text("선택한 꼬리표가 붙은 행동의 기록만 표시돼요.")
}
Section {
ForEach(actions) { action in
selectableRow(
title: action.name,
symbol: action.symbolName,
color: action.color,
selected: filterActionIDs.contains(action.persistentModelID)
) {
filterTag = nil
if filterActionIDs.contains(action.persistentModelID) {
filterActionIDs.remove(action.persistentModelID)
} else {
filterActionIDs.insert(action.persistentModelID)
}
}
}
} header: {
Text("행동으로 보기 (여러 개 선택 가능)")
} footer: {
Text("목록·타임테이블·통계 모두에 필터가 적용돼요.")
}
}
.scrollContentBackground(.hidden)
.background(AppTheme.background)
.navigationTitle("기록 필터")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
}
}
private func selectableRow(
title: String,
symbol: String,
color: Color,
selected: Bool,
onTap: @escaping () -> Void
) -> some View {
Button(action: onTap) {
HStack(spacing: 10) {
Image(systemName: symbol)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 28, height: 28)
.background(color, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(title)
.foregroundStyle(.primary)
Spacer()
if selected {
Image(systemName: "checkmark")
.fontWeight(.semibold)
.foregroundStyle(AppTheme.green)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@ -2,7 +2,7 @@
// MainView.swift
// Haru_Danim
//
// : + + (CLAUDE.md §6.1)
// : + + + (CLAUDE.md §6.1)
//
import SwiftUI
@ -14,6 +14,7 @@ struct MainView: View {
@Query(sort: \Action.sortOrder) private var actions: [Action]
@Query(filter: #Predicate<TimeSession> { $0.endAt == nil }, sort: \TimeSession.startAt)
private var runningSessions: [TimeSession]
@Query(filter: #Predicate<Goal> { $0.showsOnMain }) private var pinnedGoals: [Goal]
@AppStorage(SettingsKeys.gridColumns) private var gridColumns = 3
@AppStorage(SettingsKeys.minSessionSeconds) private var minSessionSeconds = 0
@ -28,6 +29,7 @@ struct MainView: View {
@State private var draggingAction: Action?
@State private var settingsEditingAction: Action?
@State private var recordsAction: Action?
@State private var recordsStartWithAdd = false
@State private var deletingAction: Action?
@State private var memoSession: TimeSession?
@State private var memoEntry: CountEntry?
@ -37,6 +39,9 @@ struct MainView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let goal = pinnedGoals.first, !isEditing {
GoalSummaryCard(goal: goal)
}
if anyRunning && !isEditing {
runningArea
}
@ -52,7 +57,7 @@ struct MainView: View {
.padding()
}
.background(AppTheme.background)
.navigationTitle(Text("하루다님", comment: "앱 이름"))
.navigationTitle("모음")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(isEditing ? "완료" : "배치 편집") {
@ -67,7 +72,7 @@ struct MainView: View {
ActionEditorView(action: action)
}
.sheet(item: $recordsAction) { action in
ActionRecordsSheet(action: action)
ActionRecordsSheet(action: action, startWithAdd: recordsStartWithAdd)
}
.sheet(item: $memoSession) { session in
SessionMemoSheet(session: session)
@ -174,6 +179,13 @@ struct MainView: View {
base
.contextMenu {
Button {
recordsStartWithAdd = false
recordsAction = action
} label: {
Label("기록 확인", systemImage: "list.bullet.rectangle")
}
Button {
recordsStartWithAdd = true
recordsAction = action
} label: {
Label(
@ -262,6 +274,79 @@ struct MainView: View {
}
}
// MARK: - ( , )
struct GoalSummaryCard: View {
let goal: Goal
var body: some View {
NavigationLink {
GoalDetailView(goal: goal)
} label: {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: goal.symbolName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(goal.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 1) {
Text("목표 진행 현황")
.font(.caption2)
.foregroundStyle(.secondary)
Text(goal.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
if goal.status == .inProgress, let progress = goal.dateProgress() {
VStack(alignment: .leading, spacing: 3) {
ProgressView(value: progress)
.tint(goal.color)
Text("기간 진행률 \(Format.percent(progress))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
ForEach(goal.sortedQuests.prefix(3)) { quest in
questLine(quest)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.buttonStyle(.plain)
}
/// : +
@ViewBuilder
private func questLine(_ quest: Quest) -> some View {
if let result = QuestProgress(quest: quest).current() {
let over = quest.direction == .atMost && result.value > result.target
HStack(spacing: 8) {
Text(quest.targetName)
.font(.caption)
.foregroundStyle(.primary)
.lineLimit(1)
.frame(width: 88, alignment: .leading)
ProgressView(value: result.ratio)
.tint(over ? .red : quest.targetColor)
Text(Format.percent(result.target > 0 ? result.value / result.target : 0))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(over ? .red : .secondary)
.frame(width: 40, alignment: .trailing)
}
}
}
}
// MARK: -
struct SessionMemoSheet: View {

View File

@ -14,6 +14,8 @@ struct ActionRecordsSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
/// true ( ' ' )
var startWithAdd = false
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@ -51,6 +53,8 @@ struct ActionRecordsSheet: View {
editingSession = session
} label: {
SessionRowLabel(session: session)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
@ -69,6 +73,8 @@ struct ActionRecordsSheet: View {
editingEntry = entry
} label: {
CountRowLabel(entry: entry)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
@ -100,6 +106,9 @@ struct ActionRecordsSheet: View {
CountAddView(action: action)
}
}
.onAppear {
if startWithAdd { showingAdd = true }
}
}
}
}

View File

@ -6,8 +6,10 @@
//
import SwiftUI
import SwiftData
struct SettingsView: View {
@Query(sort: \Goal.createdAt) private var goals: [Goal]
@AppStorage(SettingsKeys.theme) private var theme = "light"
@AppStorage(SettingsKeys.language) private var language = AppLanguage.ko.rawValue
@AppStorage(SettingsKeys.weekStartWeekday) private var weekStartWeekday = 2
@ -33,6 +35,17 @@ struct SettingsView: View {
}
}
/// Goal.showsOnMain ( 1 )
private var pinnedGoalSelection: Binding<PersistentIdentifier?> {
Binding {
goals.first { $0.showsOnMain }?.persistentModelID
} set: { newValue in
for goal in goals {
goal.showsOnMain = goal.persistentModelID == newValue
}
}
}
var body: some View {
Form {
Section("화면") {
@ -55,6 +68,18 @@ struct SettingsView: View {
} footer: {
Text("선택한 탭(최대 \(TabBarConfig.maxVisible)개)이 하단 탭바에 바로 표시되고, 나머지 탭은 ‘더보기’에서 열 수 있어요.")
}
Section {
Picker("목표 진행 현황 표시", selection: pinnedGoalSelection) {
Text("표시 안 함").tag(nil as PersistentIdentifier?)
ForEach(goals) { goal in
Text(goal.title).tag(Optional(goal.persistentModelID))
}
}
} header: {
Text("모음 탭")
} footer: {
Text("선택한 목표의 진행 현황이 모음 탭 상단에 표시돼요. 탭하면 목표 상세로 이동해요.")
}
Section {
Picker("주 시작 요일", selection: $weekStartWeekday) {
ForEach(1...7, id: \.self) { weekday in