From c6532e84b769273a4995910c6e064951d1f96616 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 22:04:46 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20=EB=B7=B0=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=98=20State=EB=A5=BC=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=93=B0=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Structure/RecentTodoItem.swift | 2 +- .../ViewModel/ProfileViewModel.swift | 8 ---- DevLog/UI/Profile/ProfileView.swift | 44 +++++++++++-------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/DevLog/Presentation/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/RecentTodoItem.swift index 12f92f4e..fa850b50 100644 --- a/DevLog/Presentation/Structure/RecentTodoItem.swift +++ b/DevLog/Presentation/Structure/RecentTodoItem.swift @@ -2,7 +2,7 @@ // RecentTodoItem.swift // DevLog // -// Created by Codex on 3/6/26. +// Created by opfic on 3/6/26. // import Foundation diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 8852e9ba..41b1cd85 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -68,14 +68,6 @@ final class ProfileViewModel: Store { return "\(year) Q\(quarter)" } - var resetButtonEnabled: Bool { - !state.statusMessage.isEmpty && state.showDoneButton - } - - var selectedQuarter: ProfileCompletionQuarter? { - state.completionQuarter - } - var selectedDayActivities: [ProfileSelectedDayActivity] { guard let selectedDay = state.selectedDay else { return [] } let dayStart = calendar.startOfDay(for: selectedDay.date) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 57e17240..98c4cde1 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -44,8 +44,8 @@ struct ProfileView: View { } } .focused($focusedOnStatusMessageTextField) - - if viewModel.resetButtonEnabled { + + if !viewModel.state.statusMessage.isEmpty && viewModel.state.showDoneButton { Button(action: { viewModel.send(.tapResetStatusMessageButton) }) { @@ -123,9 +123,9 @@ struct ProfileView: View { } private var activityHeatmapSection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { HStack { - Text("분기별 활동 히트맵") + Text("분기별 활동") .font(.headline) Spacer() activityTypeSelector @@ -133,28 +133,34 @@ struct ProfileView: View { quarterNavigator - if viewModel.selectedQuarter == nil { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 140) - } else if let quarter = viewModel.selectedQuarter { - ProfileHeatmapView( - quarter: quarter, - selectedActivityTypes: viewModel.state.selectedActivityTypes, - selectedDay: viewModel.state.selectedDay, - onSelectDay: { day in - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.send(.selectDay(day)) - } - } + if let quarter = viewModel.state.completionQuarter { ) - .padding(.vertical, 6) + + VStack(alignment: .leading, spacing: 10) { + Text("활동 히트맵") + .font(.subheadline) + .bold() + + ProfileHeatmapView( + quarter: quarter, + selectedActivityTypes: viewModel.state.selectedActivityTypes, + selectedDay: viewModel.state.selectedDay, + onSelectDay: { day in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.send(.selectDay(day)) + } + } + ) + .padding(.vertical, 2) + } if let selectedDay = viewModel.state.selectedDay { selectedDayDetailSection(for: selectedDay) .transition(.opacity) } } else { - EmptyView() + ProgressView() + .frame(maxWidth: .infinity, minHeight: 140) } } .padding(12) From e80a1c14a8a32c3860868c624c0c5579d48685d4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 22:09:30 +0900 Subject: [PATCH 2/9] =?UTF-8?q?ui:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 98c4cde1..c67652d9 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -186,7 +186,7 @@ struct ProfileView: View { } } } label: { - Text("편집") + Image(systemName: "line.3.horizontal.decrease") .foregroundStyle(.blue) } } From aa7084ba599727e4a6bdc5caf5e872f6054259db Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 22:16:23 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=ED=86=A0=EA=B8=80=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileView.swift | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index c67652d9..4128cd58 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -173,17 +173,19 @@ struct ProfileView: View { private var activityTypeSelector: some View { Menu { ForEach(ProfileActivityType.allCases, id: \.self) { activityType in - Button { - viewModel.send(.toggleActivityType(activityType)) - } label: { - HStack { - Text(activityType.title) - if viewModel.state.selectedActivityTypes.contains(activityType) { - Image(systemName: "checkmark") - .tint(.blue) + Toggle( + activityType.title, + isOn: Binding( + get: { viewModel.state.selectedActivityTypes.contains(activityType) }, + set: { _ in + viewModel.send(.toggleActivityType(activityType)) } - } - } + ) + ) + .disabled( + viewModel.state.selectedActivityTypes.count == 1 + && viewModel.state.selectedActivityTypes.contains(activityType) + ) } } label: { Image(systemName: "line.3.horizontal.decrease") From b64a2893dde68d67054dfbc34289022c21da4b8e Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 22:35:37 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EB=B6=84=EA=B8=B0=EB=B3=84=20?= =?UTF-8?q?=ED=99=9C=EB=93=B1=EC=9D=84=20=EC=A3=BC=EC=B0=A8=20=EB=8C=80?= =?UTF-8?q?=EB=B9=84=20=EA=B7=B8=EB=9E=98=ED=94=84=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Profile/ProfileCompletionQuarter.swift | 29 +++ .../Profile/ProfileWeeklyTrendPoint.swift | 25 +++ DevLog/Resource/Localizable.xcstrings | 20 +- DevLog/UI/Profile/ProfileTrendChartView.swift | 209 ++++++++++++++++++ DevLog/UI/Profile/ProfileView.swift | 3 + 5 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift create mode 100644 DevLog/UI/Profile/ProfileTrendChartView.swift diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift index 8cc690af..3fdd4405 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift @@ -11,6 +11,11 @@ struct ProfileCompletionQuarter: Identifiable, Hashable { var id: Date { quarterStart } let quarterStart: Date let months: [ProfileCompletionMonth] + + var weeklyTrendPoints: [ProfileWeeklyTrendPoint] { + Self.makeWeeklyTrendPoints(from: months, calendar: .current) + } + var maxCount: Int { months .flatMap { $0.weeks } @@ -19,4 +24,28 @@ struct ProfileCompletionQuarter: Identifiable, Hashable { .map { $0.createdCount + $0.completedCount } .max() ?? 0 } + + static func makeWeeklyTrendPoints( + from months: [ProfileCompletionMonth], + calendar: Calendar + ) -> [ProfileWeeklyTrendPoint] { + let days = months + .flatMap(\.weeks) + .flatMap { $0 } + .filter(\.isInMonth) + let groupedByWeekStart = Dictionary(grouping: days) { day in + calendar.dateInterval(of: .weekOfYear, for: day.date)?.start + ?? calendar.startOfDay(for: day.date) + } + + return groupedByWeekStart.keys.sorted().enumerated().map { index, weekStart in + let weekDays = groupedByWeekStart[weekStart, default: []] + return ProfileWeeklyTrendPoint( + weekStart: weekStart, + weekIndex: index + 1, + createdCount: weekDays.reduce(0) { $0 + $1.createdCount }, + completedCount: weekDays.reduce(0) { $0 + $1.completedCount } + ) + } + } } diff --git a/DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift b/DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift new file mode 100644 index 00000000..2df0be55 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileWeeklyTrendPoint.swift @@ -0,0 +1,25 @@ +// +// ProfileWeeklyTrendPoint.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Foundation + +struct ProfileWeeklyTrendPoint: Identifiable, Hashable { + var id: Date { weekStart } + let weekStart: Date + let weekIndex: Int + let createdCount: Int + let completedCount: Int + + func count(for activityType: ProfileActivityType) -> Int { + switch activityType { + case .created: + createdCount + case .completed: + completedCount + } + } +} diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index d70a97f9..d699a9d7 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -15,6 +15,9 @@ }, "%lld개 필터가 적용됨" : { + }, + "%lld주" : { + }, "https://" : { @@ -297,7 +300,7 @@ "보기 범위" : { }, - "분기별 활동 히트맵" : { + "분기별 활동" : { }, "사용자 설정" : { @@ -350,6 +353,9 @@ }, "완료일" : { + }, + "이 분기에는 선택한 활동이 없어요" : { + }, "읽지 않음" : { @@ -387,6 +393,12 @@ }, "제목" : { + }, + "주간 추세" : { + + }, + "주차" : { + }, "중요" : { @@ -450,9 +462,15 @@ }, "확인" : { + }, + "활동" : { + }, "활동 없음" : { + }, + "활동 히트맵" : { + }, "회원 탈퇴" : { diff --git a/DevLog/UI/Profile/ProfileTrendChartView.swift b/DevLog/UI/Profile/ProfileTrendChartView.swift new file mode 100644 index 00000000..666339d2 --- /dev/null +++ b/DevLog/UI/Profile/ProfileTrendChartView.swift @@ -0,0 +1,209 @@ +// +// ProfileTrendChartView.swift +// DevLog +// +// Created by opfic on 3/6/26. +// + +import Charts +import SwiftUI + +struct ProfileTrendChartView: View { + let trendPoints: [ProfileWeeklyTrendPoint] + let selectedActivityTypes: Set + + private let chartHeight: CGFloat = 190 + private let createdColor = Color.orange + private let completedColor = Color.blue + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text("주간 추세") + .font(.subheadline) + .bold() + + Spacer() + + HStack(spacing: 10) { + if selectedActivityTypes.contains(.created) { + legendItem(title: ProfileActivityType.created.title, color: createdColor) + } + if selectedActivityTypes.contains(.completed) { + legendItem(title: ProfileActivityType.completed.title, color: completedColor) + } + } + } + + if hasVisibleActivity { + Chart { + if selectedActivityTypes.contains(.created) { + createdSeries + } + + if selectedActivityTypes.contains(.completed) { + completedSeries + } + } + .chartLegend(.hidden) + .chartXScale( + domain: xDomain, + range: .plotDimension(startPadding: 4, endPadding: 14) + ) + .chartXAxis { + AxisMarks(values: axisWeekIndices) { value in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.quaternary) + AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.quaternary) + AxisValueLabel { + if let weekIndex = value.as(Int.self) { + Text("\(weekIndex)주") + .font(.caption2) + .fixedSize() + } + } + } + } + .chartYAxis { + AxisMarks(position: .leading, values: yAxisValues) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.quaternary) + AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(.quaternary) + AxisValueLabel() + } + } + .chartYScale(domain: yDomain) + .chartPlotStyle { plotArea in + plotArea + .background(Color(.systemGray6).opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .frame(height: chartHeight) + } else { + VStack(spacing: 6) { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.title3) + .foregroundStyle(.secondary) + Text("이 분기에는 선택한 활동이 없어요") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: chartHeight) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6).opacity(0.6)) + ) + } + } + } + + private var axisWeekIndices: [Int] { + let weekIndices = trendPoints.map(\.weekIndex) + guard weekIndices.count > 6 else { return weekIndices } + + var labels = Set() + let lastIndex = weekIndices.count - 1 + + for (offset, weekIndex) in weekIndices.enumerated() { + if offset == 0 || offset == lastIndex || offset.isMultiple(of: 2) { + labels.insert(weekIndex) + } + } + + return labels.sorted() + } + + private var visibleCounts: [Int] { + trendPoints.flatMap { point in + ProfileActivityType.allCases.compactMap { activityType in + guard selectedActivityTypes.contains(activityType) else { return nil } + return point.count(for: activityType) + } + } + } + + private var maxVisibleCount: Int { + max(visibleCounts.max() ?? 0, 1) + } + + private var xDomain: ClosedRange { + let upperBound = trendPoints.map(\.weekIndex).max() ?? 1 + return 1...upperBound + } + + private var yAxisValues: [Int] { + if maxVisibleCount <= 4 { + return Array(0...maxVisibleCount) + } + + let step = max(Int(ceil(Double(maxVisibleCount) / 4)), 1) + var values = Array(stride(from: 0, through: maxVisibleCount, by: step)) + if values.last != maxVisibleCount { + values.append(maxVisibleCount) + } + return values + } + + private var yDomain: ClosedRange { + let upperBound = Double(maxVisibleCount) + let upperPadding = max(0.8, upperBound * 0.15) + let lowerPadding = max(0.6, upperBound * 0.08) + return -lowerPadding...(upperBound + upperPadding) + } + + private var hasVisibleActivity: Bool { + visibleCounts.contains { $0 > 0 } + } + + @ChartContentBuilder + private var createdSeries: some ChartContent { + ForEach(trendPoints) { point in + LineMark( + x: .value("주차", point.weekIndex), + y: .value(ProfileActivityType.created.title, point.createdCount), + series: .value("활동", ProfileActivityType.created.title) + ) + .foregroundStyle(createdColor) + .lineStyle(StrokeStyle(lineWidth: 2.5)) + + PointMark( + x: .value("주차", point.weekIndex), + y: .value(ProfileActivityType.created.title, point.createdCount) + ) + .foregroundStyle(createdColor) + } + } + + @ChartContentBuilder + private var completedSeries: some ChartContent { + ForEach(trendPoints) { point in + LineMark( + x: .value("주차", point.weekIndex), + y: .value(ProfileActivityType.completed.title, point.completedCount), + series: .value("활동", ProfileActivityType.completed.title) + ) + .foregroundStyle(completedColor) + .lineStyle(StrokeStyle(lineWidth: 2.5)) + + PointMark( + x: .value("주차", point.weekIndex), + y: .value(ProfileActivityType.completed.title, point.completedCount) + ) + .foregroundStyle(completedColor) + } + } + + private func legendItem(title: String, color: Color) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 4128cd58..2ca8161d 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -134,6 +134,9 @@ struct ProfileView: View { quarterNavigator if let quarter = viewModel.state.completionQuarter { + ProfileTrendChartView( + trendPoints: viewModel.state.completionQuarter?.weeklyTrendPoints ?? [], + selectedActivityTypes: viewModel.state.selectedActivityTypes ) VStack(alignment: .leading, spacing: 10) { From 724e767f92bbde93d6250c3bff92afa2c65538cd Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 22:52:32 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=ED=98=84=EC=9E=AC=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20=EC=95=84=EB=8B=90=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EB=B6=84=EA=B8=B0=EB=A1=9C=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B5=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 18 ++++++++++++++ DevLog/UI/Profile/ProfileView.swift | 24 ++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 41b1cd85..3925efae 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -37,6 +37,7 @@ final class ProfileViewModel: Store { quarter: ProfileCompletionQuarter, dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] ) + case moveToCurrentQuarter case moveQuarter(Int) case toggleActivityType(ProfileActivityType) case selectDay(ProfileCompletionDay?) @@ -87,6 +88,14 @@ final class ProfileViewModel: Store { canMoveToQuarter(offsetMonths: 3) } + var isViewingCurrentQuarter: Bool { + guard let selectedQuarterStart = state.selectedQuarterStart, + let currentQuarterStart = quarterStart(for: Date(), calendar: calendar) else { + return false + } + return selectedQuarterStart == currentQuarterStart + } + init( fetchUserDataUseCase: FetchUserDataUseCase, fetchTodosUseCase: FetchTodosUseCase, @@ -141,6 +150,15 @@ final class ProfileViewModel: Store { } case .setSelectedActivityForSheet(let activity): state.selectedActivityForSheet = activity + case .moveToCurrentQuarter: + guard let currentQuarterStart = quarterStart(for: Date(), calendar: calendar), + state.selectedQuarterStart != currentQuarterStart else { break } + state.selectedQuarterStart = currentQuarterStart + state.completionQuarter = nil + state.dayActivitiesByDate = [:] + state.selectedDay = nil + state.selectedActivityForSheet = nil + effects = [.fetchCompletionQuarter(currentQuarterStart)] case .moveQuarter(let delta): guard let selectedQuarterStart = state.selectedQuarterStart else { break } let monthDelta = 3 * delta diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 2ca8161d..5ff19e37 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -128,6 +128,7 @@ struct ProfileView: View { Text("분기별 활동") .font(.headline) Spacer() + quarterResetButton activityTypeSelector } @@ -173,6 +174,20 @@ struct ProfileView: View { ) } + @ViewBuilder + private var quarterResetButton: some View { + if !viewModel.isViewingCurrentQuarter { + Button { + viewModel.send(.moveToCurrentQuarter) + } label: { + Image(systemName: "arrow.uturn.backward") + .bold() + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + } + } + private var activityTypeSelector: some View { Menu { ForEach(ProfileActivityType.allCases, id: \.self) { activityType in @@ -192,6 +207,7 @@ struct ProfileView: View { } } label: { Image(systemName: "line.3.horizontal.decrease") + .bold() .foregroundStyle(.blue) } } @@ -204,15 +220,11 @@ struct ProfileView: View { Image(systemName: "chevron.left") } .disabled(!viewModel.canMoveToPreviousQuarter) - Spacer() - Text(viewModel.quarterTitle) - .font(.subheadline) - .foregroundStyle(.secondary) - + .font(.subheadline) + .foregroundStyle(.secondary) Spacer() - Button { viewModel.send(.moveQuarter(1)) } label: { From 8c9a80bfd48f62d976af00c865e8efbc342b3964 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 23:20:03 +0900 Subject: [PATCH 6/9] =?UTF-8?q?style:=20=EB=B3=80=EC=88=98=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 5ff19e37..0166c8ae 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -11,7 +11,7 @@ struct ProfileView: View { @State var viewModel: ProfileViewModel @State private var router = NavigationRouter() @Environment(\.diContainer) private var container - @FocusState private var focusedOnStatusMessageTextField: Bool + @FocusState private var focused: Bool var body: some View { NavigationStack(path: $router.path) { @@ -43,7 +43,7 @@ struct ProfileView: View { Text("상태 설정") } } - .focused($focusedOnStatusMessageTextField) + .focused($focused) if !viewModel.state.statusMessage.isEmpty && viewModel.state.showDoneButton { Button(action: { @@ -62,7 +62,7 @@ struct ProfileView: View { ) if viewModel.state.showDoneButton { Button(action: { - focusedOnStatusMessageTextField = false + focused = false viewModel.send(.willUpdateStatusMessage) }) { Text("완료") @@ -105,7 +105,7 @@ struct ProfileView: View { .onAppear { viewModel.send(.onAppear) } - .onChange(of: focusedOnStatusMessageTextField) { _, newValue in + .onChange(of: focused) { _, newValue in withAnimation { viewModel.send(.updateStatusTextFieldFocus(newValue)) } From 03797f2768b76d393c84af375a1674ed5078325f Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 23:48:35 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=EB=85=84=EB=8F=84=EC=99=80=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EB=A5=BC=20=EC=84=A0=ED=83=9D=ED=95=A0=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=8B=9C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 195 +++++++++++++----- DevLog/Resource/Localizable.xcstrings | 9 + DevLog/UI/Profile/ProfileView.swift | 84 +++++++- 3 files changed, 234 insertions(+), 54 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 3925efae..b4c772bb 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -14,7 +14,10 @@ final class ProfileViewModel: Store { var email: String = "" var statusMessage: String = "" var avatarURL: URL? + var earliestQuarterStart: Date? var selectedQuarterStart: Date? + var showQuarterPicker: Bool = false + var selectedQuarterPickerYear = Calendar.current.component(.year, from: Date()) var completionQuarter: ProfileCompletionQuarter? var dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] var selectedActivityTypes: Set = [.created, .completed] @@ -37,6 +40,11 @@ final class ProfileViewModel: Store { quarter: ProfileCompletionQuarter, dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] ) + case setEarliestQuarterStart(Date) + case setQuarterPickerPresented(Bool) + case setQuarterPickerYear(Int) + case openQuarterPicker + case selectQuarter(Date) case moveToCurrentQuarter case moveQuarter(Int) case toggleActivityType(ProfileActivityType) @@ -48,6 +56,7 @@ final class ProfileViewModel: Store { enum SideEffect { case fetchUserData + case fetchEarliestQuarterStart case fetchCompletionQuarter(Date) case updateStatusMessage(String) case updateHeatmapActivityTypes(Set) @@ -61,41 +70,6 @@ final class ProfileViewModel: Store { private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase private let calendar = Calendar.current - var quarterTitle: String { - guard let start = state.selectedQuarterStart else { return "" } - let year = calendar.component(.year, from: start) - let month = calendar.component(.month, from: start) - let quarter = ((month - 1) / 3) + 1 - return "\(year) Q\(quarter)" - } - - var selectedDayActivities: [ProfileSelectedDayActivity] { - guard let selectedDay = state.selectedDay else { return [] } - let dayStart = calendar.startOfDay(for: selectedDay.date) - let activities = state.dayActivitiesByDate[dayStart] ?? [] - - return activities.filter { activity in - (state.selectedActivityTypes.contains(.created) && activity.showsCreated) - || (state.selectedActivityTypes.contains(.completed) && activity.showsCompleted) - } - } - - var canMoveToPreviousQuarter: Bool { - canMoveToQuarter(offsetMonths: -3) - } - - var canMoveToNextQuarter: Bool { - canMoveToQuarter(offsetMonths: 3) - } - - var isViewingCurrentQuarter: Bool { - guard let selectedQuarterStart = state.selectedQuarterStart, - let currentQuarterStart = quarterStart(for: Date(), calendar: calendar) else { - return false - } - return selectedQuarterStart == currentQuarterStart - } - init( fetchUserDataUseCase: FetchUserDataUseCase, fetchTodosUseCase: FetchTodosUseCase, @@ -117,15 +91,19 @@ final class ProfileViewModel: Store { switch action { case .onAppear: if state.selectedQuarterStart == nil { - guard let quarterStart = quarterStart(for: Date(), calendar: calendar) else { break } + guard let quarterStart = quarterStart(for: Date()) else { break } state.selectedQuarterStart = quarterStart } + effects = [.fetchUserData] + if state.earliestQuarterStart == nil { + state.earliestQuarterStart = state.selectedQuarterStart + effects.append(.fetchEarliestQuarterStart) + } let rawValues = fetchHeatmapActivityTypesUseCase.execute() let settings = normalizeActivityTypes(rawValues) if !settings.isEmpty { state.selectedActivityTypes = settings } - effects = [.fetchUserData] if let selectedQuarterStart = state.selectedQuarterStart { effects.append(.fetchCompletionQuarter(selectedQuarterStart)) } @@ -138,6 +116,17 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL + case .setEarliestQuarterStart(let quarterStart): + state.earliestQuarterStart = quarterStart + case .setQuarterPickerPresented(let isPresented): + state.showQuarterPicker = isPresented + case .setQuarterPickerYear(let year): + state.selectedQuarterPickerYear = year + case .openQuarterPicker: + if let selectedQuarterStart = state.selectedQuarterStart { + state.selectedQuarterPickerYear = calendar.component(.year, from: selectedQuarterStart) + } + state.showQuarterPicker = true case .setCompletionQuarter(let quarterStart, let quarter, let dayActivitiesByDate): guard state.selectedQuarterStart == quarterStart else { break } state.completionQuarter = quarter @@ -150,15 +139,14 @@ final class ProfileViewModel: Store { } case .setSelectedActivityForSheet(let activity): state.selectedActivityForSheet = activity + case .selectQuarter(let quarterStart): + guard canSelectQuarter(quarterStart) else { break } + state.showQuarterPicker = false + updateSelectedQuarter(to: quarterStart, state: &state, effects: &effects) case .moveToCurrentQuarter: - guard let currentQuarterStart = quarterStart(for: Date(), calendar: calendar), + guard let currentQuarterStart = quarterStart(for: Date()), state.selectedQuarterStart != currentQuarterStart else { break } - state.selectedQuarterStart = currentQuarterStart - state.completionQuarter = nil - state.dayActivitiesByDate = [:] - state.selectedDay = nil - state.selectedActivityForSheet = nil - effects = [.fetchCompletionQuarter(currentQuarterStart)] + updateSelectedQuarter(to: currentQuarterStart, state: &state, effects: &effects) case .moveQuarter(let delta): guard let selectedQuarterStart = state.selectedQuarterStart else { break } let monthDelta = 3 * delta @@ -169,13 +157,7 @@ final class ProfileViewModel: Store { ) else { break } let today = calendar.startOfDay(for: Date()) guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } - - state.selectedQuarterStart = nextQuarterStart - state.completionQuarter = nil - state.dayActivitiesByDate = [:] - state.selectedDay = nil - state.selectedActivityForSheet = nil - effects = [.fetchCompletionQuarter(nextQuarterStart)] + updateSelectedQuarter(to: nextQuarterStart, state: &state, effects: &effects) case .toggleActivityType(let activityType): if state.selectedActivityTypes.contains(activityType), state.selectedActivityTypes.count == 1 { break @@ -211,6 +193,15 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } + case .fetchEarliestQuarterStart: + Task { + do { + let earliestQuarterStart = try await fetchEarliestQuarterStart() + send(.setEarliestQuarterStart(earliestQuarterStart)) + } catch { + send(.setAlert(true)) + } + } case .fetchCompletionQuarter(let quarterStart): Task { do { @@ -246,7 +237,79 @@ final class ProfileViewModel: Store { } } +extension ProfileViewModel { + var quarterTitle: String { + guard let start = state.selectedQuarterStart else { return "" } + let year = calendar.component(.year, from: start) + let month = calendar.component(.month, from: start) + let quarter = ((month - 1) / 3) + 1 + return "\(year) Q\(quarter)" + } + + var selectedDayActivities: [ProfileSelectedDayActivity] { + guard let selectedDay = state.selectedDay else { return [] } + let dayStart = calendar.startOfDay(for: selectedDay.date) + let activities = state.dayActivitiesByDate[dayStart] ?? [] + + return activities.filter { activity in + (state.selectedActivityTypes.contains(.created) && activity.showsCreated) + || (state.selectedActivityTypes.contains(.completed) && activity.showsCompleted) + } + } + + var canMoveToPreviousQuarter: Bool { + canMoveToQuarter(offsetMonths: -3) + } + + var canMoveToNextQuarter: Bool { + canMoveToQuarter(offsetMonths: 3) + } + + var isViewingCurrentQuarter: Bool { + guard let selectedQuarterStart = state.selectedQuarterStart, + let currentQuarterStart = quarterStart(for: Date()) else { + return false + } + return selectedQuarterStart == currentQuarterStart + } + + var availableQuarterYears: [Int] { + guard let earliestQuarterStart = state.earliestQuarterStart, + let currentQuarterStart = quarterStart(for: Date()) else { return [state.selectedQuarterPickerYear] } + let earliestYear = calendar.component(.year, from: earliestQuarterStart) + let currentYear = calendar.component(.year, from: currentQuarterStart) + return Array(stride(from: currentYear, through: earliestYear, by: -1)) + } + + func quarterStartForPicker(quarter: Int) -> Date? { + quarterStart(year: state.selectedQuarterPickerYear, quarter: quarter) + } + + func isQuarterSelectableForPicker(_ quarter: Int) -> Bool { + guard let quarterStart = quarterStartForPicker(quarter: quarter) else { return false } + return canSelectQuarter(quarterStart) + } + + func isQuarterSelectedForPicker(_ quarter: Int) -> Bool { + quarterStartForPicker(quarter: quarter) == state.selectedQuarterStart + } +} + private extension ProfileViewModel { + func updateSelectedQuarter( + to quarterStart: Date, + state: inout State, + effects: inout [SideEffect] + ) { + guard state.selectedQuarterStart != quarterStart else { return } + state.selectedQuarterStart = quarterStart + state.completionQuarter = nil + state.dayActivitiesByDate = [:] + state.selectedDay = nil + state.selectedActivityForSheet = nil + effects = [.fetchCompletionQuarter(quarterStart)] + } + func makeDayActivitiesByDate(from todos: [Todo]) -> [Date: [ProfileSelectedDayActivity]] { var activitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] @@ -302,6 +365,19 @@ private extension ProfileViewModel { return page.items } + func fetchEarliestQuarterStart() async throws -> Date { + let page = try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .createdAt, + sortOrder: .oldest, + pageSize: 1 + ), + cursor: nil + ) + let baseDate = page.items.first?.createdAt ?? Date() + return quarterStart(for: baseDate) ?? calendar.startOfDay(for: baseDate) + } + func canMove(to quarterStart: Date, calendar: Calendar, today: Date) -> Bool { guard let quarterEnd = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { return false @@ -310,6 +386,12 @@ private extension ProfileViewModel { return interval.contains(today) || quarterEnd <= today } + func canSelectQuarter(_ quarterStart: Date) -> Bool { + guard let earliestQuarterStart = state.earliestQuarterStart, + let currentQuarterStart = self.quarterStart(for: Date()) else { return false } + return earliestQuarterStart <= quarterStart && quarterStart <= currentQuarterStart + } + func normalizeActivityTypes(_ rawValues: [String]) -> Set { Set(rawValues.compactMap(ProfileActivityType.init(rawValue:))) } @@ -385,7 +467,7 @@ private extension ProfileViewModel { return ProfileCompletionMonth(monthStart: monthStart, weeks: weeks) } - func quarterStart(for date: Date, calendar: Calendar) -> Date? { + func quarterStart(for date: Date) -> Date? { let month = calendar.component(.month, from: date) let startMonth = ((month - 1) / 3) * 3 + 1 var components = calendar.dateComponents([.year], from: date) @@ -394,6 +476,15 @@ private extension ProfileViewModel { return calendar.date(from: components) } + func quarterStart(year: Int, quarter: Int) -> Date? { + guard (1...4).contains(quarter) else { return nil } + var components = DateComponents() + components.year = year + components.month = ((quarter - 1) * 3) + 1 + components.day = 1 + return calendar.date(from: components) + } + func canMoveToQuarter(offsetMonths: Int) -> Bool { guard let selectedQuarterStart = state.selectedQuarterStart else { return false } guard let targetQuarterStart = calendar.date( diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index d699a9d7..9d149632 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -76,6 +76,9 @@ } } } + }, + "Q%lld" : { + }, "rainbow" : { "extractionState" : "manual", @@ -299,6 +302,9 @@ }, "보기 범위" : { + }, + "분기 선택" : { + }, "분기별 활동" : { @@ -338,6 +344,9 @@ }, "알림" : { + }, + "연도" : { + }, "연동된 계정" : { diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 0166c8ae..6387f12f 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -119,6 +119,12 @@ struct ProfileView: View { } message: { Text(viewModel.state.alertMessage) } + .sheet(isPresented: Binding( + get: { viewModel.state.showQuarterPicker }, + set: { viewModel.send(.setQuarterPickerPresented($0)) } + )) { + quarterPickerSheet + } } } @@ -221,9 +227,18 @@ struct ProfileView: View { } .disabled(!viewModel.canMoveToPreviousQuarter) Spacer() - Text(viewModel.quarterTitle) - .font(.subheadline) + Button { + viewModel.send(.openQuarterPicker) + } label: { + HStack(spacing: 4) { + Text(viewModel.quarterTitle) + .font(.subheadline) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2) + } .foregroundStyle(.secondary) + } + .buttonStyle(.plain) Spacer() Button { viewModel.send(.moveQuarter(1)) @@ -234,6 +249,71 @@ struct ProfileView: View { } } + private var quarterPickerSheet: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("연도") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Picker("", selection: Binding( + get: { viewModel.state.selectedQuarterPickerYear }, + set: { viewModel.send(.setQuarterPickerYear($0)) } + )) { + ForEach(viewModel.availableQuarterYears, id: \.self) { year in + Text(year.formatted(.number.grouping(.never)) + "년").tag(year) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { + ForEach(1...4, id: \.self) { quarter in + quarterSelectionButton(for: quarter) + } + } + + Spacer(minLength: 0) + } + .padding(20) + .navigationTitle("분기 선택") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarTrailingButton { + viewModel.send(.setQuarterPickerPresented(false)) + } + } + } + .presentationDetents([.fraction(0.3)]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private func quarterSelectionButton(for quarter: Int) -> some View { + let quarterStart = viewModel.quarterStartForPicker(quarter: quarter) + let isEnabled = viewModel.isQuarterSelectableForPicker(quarter) + let isSelected = viewModel.isQuarterSelectedForPicker(quarter) + + Button { + guard let quarterStart else { return } + viewModel.send(.selectQuarter(quarterStart)) + } label: { + Text("Q\(quarter)") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.blue : Color(.systemGray5)) + ) + .foregroundStyle(isSelected ? .white : isEnabled ? .primary : .secondary) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + } + @ViewBuilder private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { let activities = viewModel.selectedDayActivities From 94b6049f75c1a776d63ca989b3c0affe9591ec2e Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 23:57:30 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EB=B2=84=ED=8A=BC=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EA=B8=B0=20=EC=9D=B4=EB=8F=99=EB=8F=84=20?= =?UTF-8?q?earlist=20Todo=20=EB=B2=94=EC=9C=84=20=EB=82=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=B4=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/ViewModel/ProfileViewModel.swift | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index b4c772bb..6bba3662 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -155,8 +155,7 @@ final class ProfileViewModel: Store { value: monthDelta, to: selectedQuarterStart ) else { break } - let today = calendar.startOfDay(for: Date()) - guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } + guard canSelectQuarter(nextQuarterStart) else { break } updateSelectedQuarter(to: nextQuarterStart, state: &state, effects: &effects) case .toggleActivityType(let activityType): if state.selectedActivityTypes.contains(activityType), state.selectedActivityTypes.count == 1 { @@ -378,14 +377,6 @@ private extension ProfileViewModel { return quarterStart(for: baseDate) ?? calendar.startOfDay(for: baseDate) } - func canMove(to quarterStart: Date, calendar: Calendar, today: Date) -> Bool { - guard let quarterEnd = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { - return false - } - let interval = DateInterval(start: quarterStart, end: quarterEnd) - return interval.contains(today) || quarterEnd <= today - } - func canSelectQuarter(_ quarterStart: Date) -> Bool { guard let earliestQuarterStart = state.earliestQuarterStart, let currentQuarterStart = self.quarterStart(for: Date()) else { return false } @@ -492,7 +483,6 @@ private extension ProfileViewModel { else { return false } - let today = calendar.startOfDay(for: Date()) - return canMove(to: targetQuarterStart, calendar: calendar, today: today) + return canSelectQuarter(targetQuarterStart) } } From 5a589e113e6b8073f2b77e9a6b2361f4dc983d5e Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 23:59:47 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20ProfileHeatmapView=EC=97=90=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileHeatmapView.swift | 34 +++++++++++++--------- DevLog/UI/Profile/ProfileView.swift | 27 ++++++----------- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index 332b7c4e..2296eb70 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -14,22 +14,28 @@ struct ProfileHeatmapView: View { let onSelectDay: (ProfileCompletionDay) -> Void var body: some View { - 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() + 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() + } } } + .padding(.vertical, 2) } } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 6387f12f..7673b227 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -145,25 +145,16 @@ struct ProfileView: View { trendPoints: viewModel.state.completionQuarter?.weeklyTrendPoints ?? [], selectedActivityTypes: viewModel.state.selectedActivityTypes ) - - VStack(alignment: .leading, spacing: 10) { - Text("활동 히트맵") - .font(.subheadline) - .bold() - - ProfileHeatmapView( - quarter: quarter, - selectedActivityTypes: viewModel.state.selectedActivityTypes, - selectedDay: viewModel.state.selectedDay, - onSelectDay: { day in - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.send(.selectDay(day)) - } + ProfileHeatmapView( + quarter: quarter, + selectedActivityTypes: viewModel.state.selectedActivityTypes, + selectedDay: viewModel.state.selectedDay, + onSelectDay: { day in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.send(.selectDay(day)) } - ) - .padding(.vertical, 2) - } - + } + ) if let selectedDay = viewModel.state.selectedDay { selectedDayDetailSection(for: selectedDay) .transition(.opacity)