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/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..6bba3662 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,12 @@ 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) case selectDay(ProfileCompletionDay?) @@ -47,6 +56,7 @@ final class ProfileViewModel: Store { enum SideEffect { case fetchUserData + case fetchEarliestQuarterStart case fetchCompletionQuarter(Date) case updateStatusMessage(String) case updateHeatmapActivityTypes(Set) @@ -60,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 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) - 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) - } - init( fetchUserDataUseCase: FetchUserDataUseCase, fetchTodosUseCase: FetchTodosUseCase, @@ -116,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)) } @@ -137,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 @@ -149,6 +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()), + state.selectedQuarterStart != currentQuarterStart else { break } + updateSelectedQuarter(to: currentQuarterStart, state: &state, effects: &effects) case .moveQuarter(let delta): guard let selectedQuarterStart = state.selectedQuarterStart else { break } let monthDelta = 3 * delta @@ -157,15 +155,8 @@ 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 } - - state.selectedQuarterStart = nextQuarterStart - state.completionQuarter = nil - state.dayActivitiesByDate = [:] - state.selectedDay = nil - state.selectedActivityForSheet = nil - effects = [.fetchCompletionQuarter(nextQuarterStart)] + 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 { break @@ -201,6 +192,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 { @@ -236,7 +236,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]] = [:] @@ -292,12 +364,23 @@ private extension ProfileViewModel { return page.items } - 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 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 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 { @@ -375,7 +458,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) @@ -384,6 +467,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( @@ -391,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) } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index d70a97f9..9d149632 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -15,6 +15,9 @@ }, "%lld개 필터가 적용됨" : { + }, + "%lld주" : { + }, "https://" : { @@ -73,6 +76,9 @@ } } } + }, + "Q%lld" : { + }, "rainbow" : { "extractionState" : "manual", @@ -297,7 +303,10 @@ "보기 범위" : { }, - "분기별 활동 히트맵" : { + "분기 선택" : { + + }, + "분기별 활동" : { }, "사용자 설정" : { @@ -335,6 +344,9 @@ }, "알림" : { + }, + "연도" : { + }, "연동된 계정" : { @@ -350,6 +362,9 @@ }, "완료일" : { + }, + "이 분기에는 선택한 활동이 없어요" : { + }, "읽지 않음" : { @@ -387,6 +402,12 @@ }, "제목" : { + }, + "주간 추세" : { + + }, + "주차" : { + }, "중요" : { @@ -450,9 +471,15 @@ }, "확인" : { + }, + "활동" : { + }, "활동 없음" : { + }, + "활동 히트맵" : { + }, "회원 탈퇴" : { 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/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 57e17240..7673b227 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,9 +43,9 @@ struct ProfileView: View { Text("상태 설정") } } - .focused($focusedOnStatusMessageTextField) - - if viewModel.resetButtonEnabled { + .focused($focused) + + if !viewModel.state.statusMessage.isEmpty && viewModel.state.showDoneButton { Button(action: { viewModel.send(.tapResetStatusMessageButton) }) { @@ -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)) } @@ -119,24 +119,32 @@ struct ProfileView: View { } message: { Text(viewModel.state.alertMessage) } + .sheet(isPresented: Binding( + get: { viewModel.state.showQuarterPicker }, + set: { viewModel.send(.setQuarterPickerPresented($0)) } + )) { + quarterPickerSheet + } } } private var activityHeatmapSection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 16) { HStack { - Text("분기별 활동 히트맵") + Text("분기별 활동") .font(.headline) Spacer() + quarterResetButton activityTypeSelector } quarterNavigator - if viewModel.selectedQuarter == nil { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 140) - } else if let quarter = viewModel.selectedQuarter { + if let quarter = viewModel.state.completionQuarter { + ProfileTrendChartView( + trendPoints: viewModel.state.completionQuarter?.weeklyTrendPoints ?? [], + selectedActivityTypes: viewModel.state.selectedActivityTypes + ) ProfileHeatmapView( quarter: quarter, selectedActivityTypes: viewModel.state.selectedActivityTypes, @@ -147,14 +155,13 @@ struct ProfileView: View { } } ) - .padding(.vertical, 6) - if let selectedDay = viewModel.state.selectedDay { selectedDayDetailSection(for: selectedDay) .transition(.opacity) } } else { - EmptyView() + ProgressView() + .frame(maxWidth: .infinity, minHeight: 140) } } .padding(12) @@ -164,23 +171,40 @@ 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 - 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: { - Text("편집") + Image(systemName: "line.3.horizontal.decrease") + .bold() .foregroundStyle(.blue) } } @@ -193,15 +217,20 @@ struct ProfileView: View { Image(systemName: "chevron.left") } .disabled(!viewModel.canMoveToPreviousQuarter) - Spacer() - - Text(viewModel.quarterTitle) - .font(.subheadline) - .foregroundStyle(.secondary) - + 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)) } label: { @@ -211,6 +240,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