Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ struct ProfileCompletionDay: Hashable {
let date: Date
let createdCount: Int
let completedCount: Int
let isInMonth: Bool
let isVisible: Bool
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct ProfileCompletionQuarter: Identifiable, Hashable {
months
.flatMap { $0.weeks }
.flatMap { $0 }
.filter { $0.isInMonth }
.filter { $0.isVisible }
.map { $0.createdCount + $0.completedCount }
.max() ?? 0
}
Expand All @@ -32,7 +32,7 @@ struct ProfileCompletionQuarter: Identifiable, Hashable {
let days = months
.flatMap(\.weeks)
.flatMap { $0 }
.filter(\.isInMonth)
.filter(\.isVisible)
let groupedByWeekStart = Dictionary(grouping: days) { day in
calendar.dateInterval(of: .weekOfYear, for: day.date)?.start
?? calendar.startOfDay(for: day.date)
Expand Down
2 changes: 1 addition & 1 deletion DevLog/Presentation/ViewModel/ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ private extension ProfileViewModel {
date: normalizedDate,
createdCount: createdCount,
completedCount: completedCount,
isInMonth: isInMonth
isVisible: isInMonth
)
)
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
Expand Down
118 changes: 85 additions & 33 deletions DevLog/UI/Profile/ProfileHeatmapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,35 @@
import SwiftUI

struct ProfileHeatmapView: View {
@Environment(\.safeAreaInsets) private var safeAreaInsets
@Environment(\.sceneWidth) private var sceneWidth
let quarter: ProfileCompletionQuarter
let selectedActivityTypes: Set<ProfileActivityType>
let selectedDay: ProfileCompletionDay?
let onSelectDay: (ProfileCompletionDay) -> Void

var body: some View {
let layout = ProfileHeatmapLayout(
availableWidth: availableWidth,
weekCounts: quarter.months.map(\.weeks.count)
)

VStack(alignment: .leading, spacing: 10) {
Text("활동 히트맵")
.font(.subheadline)
.bold()
HStack(alignment: .top, spacing: 0) {
weekdayLabel
.padding(.trailing, 10)
let months = quarter.months
ForEach(Array(zip(months.indices, months)), id: \.1) { index, month in
MonthCompactHeatmapView(
month: month,
maxCount: quarter.maxCount,
selectedActivityTypes: selectedActivityTypes,
selectedDay: selectedDay,
onSelectDay: onSelectDay
)
if index < months.count - 1 {
Spacer()
weekdayLabel(layout: layout)
HStack(alignment: .top, spacing: layout.monthSpacing) {
ForEach(quarter.months) { month in
MonthCompactHeatmapView(
month: month,
maxCount: quarter.maxCount,
layout: layout,
selectedActivityTypes: selectedActivityTypes,
selectedDay: selectedDay,
onSelectDay: onSelectDay
)
}
}
}
Expand All @@ -40,74 +45,121 @@ struct ProfileHeatmapView: View {
}

@ViewBuilder
private var weekdayLabel: some View {
private func weekdayLabel(layout: ProfileHeatmapLayout) -> some View {
let labels: [Int: String] = [
2: "월",
4: "수",
6: "금"
]
let orderedWeekdays = Array(1...7)
let cellSize: CGFloat = 16

VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: layout.cellSpacing) {
ForEach(orderedWeekdays, id: \.self) { weekday in
Group {
if let label = labels[weekday] {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
.frame(width: cellSize, height: cellSize)
.frame(
width: layout.cellSize,
height: layout.cellSize,
alignment: .leading
)
} else {
Color.clear
.frame(width: cellSize, height: cellSize)
.frame(
width: layout.cellSize,
height: layout.cellSize
)
}
}
}
}
.padding(.top, 22)
.padding(.top, layout.weekdayTopPadding)
}

private var availableWidth: CGFloat {
// ProfileView의 바깥 가로 패딩(16)과 히트맵 카드 내부 패딩(12)을 합한 값
let horizontalPadding: CGFloat = 16 + 12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

12라는 값은 어디서 온 것인가요? 매직 넘버로 보입니다. 이 값이 무엇을 의미하는지 설명하는 주석을 추가하거나, 의미를 알 수 있는 상수로 정의하여 가독성을 높이는 것이 좋겠습니다. 예를 들어, private let outerPadding: CGFloat = 16private let innerPadding: CGFloat = 12 와 같이요. 만약 이 값이 다른 곳의 패딩 값과 관련이 있다면, 그 값을 직접 참조하는 것이 더 안전할 것입니다.

return max(
0,
sceneWidth
- safeAreaInsets.leading
- safeAreaInsets.trailing
- (horizontalPadding * 2)
)
}
}

private struct ProfileHeatmapLayout {
let cellSize: CGFloat
let cellSpacing: CGFloat = 4
let monthSpacing: CGFloat = 12
let monthTitleSpacing: CGFloat = 6

init(availableWidth: CGFloat, weekCounts: [Int]) {
let sanitizedWeekCounts = weekCounts.filter { 0 < $0 }
let totalColumns = max(sanitizedWeekCounts.reduce(0, +), 1)
let totalColumnSpacings = sanitizedWeekCounts.reduce(0) { partialResult, count in
partialResult + max(count - 1, 0)
}
let fixedWidth = monthSpacing * CGFloat(max(sanitizedWeekCounts.count - 1, 0))
+ cellSpacing * CGFloat(totalColumnSpacings)
cellSize = max(0, availableWidth - fixedWidth) / CGFloat(totalColumns + 1)
}

var weekdayTopPadding: CGFloat {
cellSize + monthTitleSpacing
}

var cellCornerRadius: CGFloat {
max(2, cellSize * 0.2)
}

var innerSelectionLineWidth: CGFloat {
max(1.2, cellSize * 0.12)
}
}

private struct MonthCompactHeatmapView: View {
@Environment(\.colorScheme) private var colorScheme
let month: ProfileCompletionMonth
let maxCount: Int
let layout: ProfileHeatmapLayout
let selectedActivityTypes: Set<ProfileActivityType>
let selectedDay: ProfileCompletionDay?
let onSelectDay: (ProfileCompletionDay) -> Void
private let orderedWeekdays = Array(1...7)
private let cellSize: CGFloat = 16
private let cellSpacing: CGFloat = 4

var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: layout.monthTitleSpacing) {
Text(month.monthStart.formatted(.dateTime.month(.abbreviated)))
.frame(height: cellSize)
.frame(height: layout.cellSize)
.font(.caption)
.foregroundStyle(.secondary)

VStack(alignment: .leading, spacing: cellSpacing) {
VStack(alignment: .leading, spacing: layout.cellSpacing) {
ForEach(orderedWeekdays, id: \.self) { weekday in
HStack(spacing: cellSpacing) {
HStack(spacing: layout.cellSpacing) {
ForEach(month.weeks.indices, id: \.self) { weekIndex in
let day = month.weeks[weekIndex].first {
Calendar.current.component(.weekday, from: $0.date) == weekday
}

RoundedRectangle(cornerRadius: 3)
RoundedRectangle(cornerRadius: layout.cellCornerRadius)
.fill(fillColor(for: day, with: maxCount))
.overlay(
RoundedRectangle(cornerRadius: 3)
.stroke(selectionInnerBorderColor(for: day), lineWidth: 2)
.stroke(
selectionInnerBorderColor(for: day),
lineWidth: layout.innerSelectionLineWidth
)
Comment thread
opficdev marked this conversation as resolved.
.overlay(
RoundedRectangle(cornerRadius: 4)
RoundedRectangle(cornerRadius: layout.cellCornerRadius + 1)
.stroke(selectionOuterBorderColor(for: day), lineWidth: 0.8)
.padding(-1)
)
.frame(width: cellSize, height: cellSize)
.frame(width: layout.cellSize, height: layout.cellSize)
.onTapGesture {
if let day, day.isInMonth {
if let day, day.isVisible {
onSelectDay(day)
}
}
Expand Down Expand Up @@ -135,7 +187,7 @@ private struct MonthCompactHeatmapView: View {
}

private func fillColor(for day: ProfileCompletionDay?, with maxCount: Int) -> Color {
guard let day, day.isInMonth else { return .clear }
guard let day, day.isVisible else { return .clear }
let count = dayCount(for: day)
if count == 0 {
return Color(.systemGray5)
Expand Down