From 4a2a144ce49825810f57bab02aff464a67236306 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 1 Mar 2026 23:49:31 +0900 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20=EB=A7=A4=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=EC=9D=98=20Todo=EC=9D=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20/=20=EC=99=84=EB=A3=8C=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=B4=20=EB=A7=B5=EC=9D=84=20=EB=B3=B4=EC=97=AC=EC=A3=BC?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 220 +++++++++++++++++- DevLog/Resource/Localizable.xcstrings | 3 + DevLog/UI/Common/MainView.swift | 1 + DevLog/UI/Profile/ProfileView.swift | 214 ++++++++++++++++- 4 files changed, 430 insertions(+), 8 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 1da72e6..4e1f202 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -8,11 +8,46 @@ import Foundation final class ProfileViewModel: Store { + enum HeatmapMetric: String, CaseIterable, Hashable { + case created + case completed + + var title: String { + switch self { + case .created: return "생성" + case .completed: return "완료" + } + } + } + + struct CompletionDay: Hashable { + let date: Date + let createdCount: Int + let completedCount: Int + let isInMonth: Bool + } + + struct CompletionMonth: Identifiable, Hashable { + var id: Date { monthStart } + let monthStart: Date + let weeks: [[CompletionDay]] + } + + struct CompletionQuarter: Identifiable, Hashable { + let quarterStart: Date + let months: [CompletionMonth] + + var id: Date { quarterStart } + } + struct State { var name: String = "" var email: String = "" var statusMessage: String = "" var avatarURL: URL? + var completionQuarters: [CompletionQuarter] = [] + var selectedQuarterIndex: Int = 0 + var selectedMetrics: Set = [.created, .completed] var showDoneButton: Bool = false var showAlert: Bool = false var alertTitle: String = "" @@ -20,6 +55,19 @@ final class ProfileViewModel: Store { var resetButtonEnabled: Bool { !statusMessage.isEmpty && showDoneButton } + + var selectedQuarter: CompletionQuarter? { + guard completionQuarters.indices.contains(selectedQuarterIndex) else { return nil } + return completionQuarters[selectedQuarterIndex] + } + + var canMoveToPreviousQuarter: Bool { + selectedQuarterIndex > 0 + } + + var canMoveToNextQuarter: Bool { + selectedQuarterIndex < completionQuarters.count - 1 + } } enum Action { @@ -28,24 +76,31 @@ final class ProfileViewModel: Store { case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) + case setCompletionQuarters([CompletionQuarter]) + case moveQuarter(Int) + case toggleHeatmapMetric(HeatmapMetric) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) } enum SideEffect { case fetchUserData + case fetchCompletionMonths case updateStatusMessage(String) } + @Published private(set) var state = State() private let fetchUserDataUseCase: FetchUserDataUseCase + private let fetchTodosByKindUseCase: FetchTodosByKindUseCase private let upsertStatusMessageUseCase: UpsertStatusMessageUseCase - @Published private(set) var state = State() init( fetchUserDataUseCase: FetchUserDataUseCase, + fetchTodosByKindUseCase: FetchTodosByKindUseCase, upsertStatusMessageUseCase: UpsertStatusMessageUseCase ) { self.fetchUserDataUseCase = fetchUserDataUseCase + self.fetchTodosByKindUseCase = fetchTodosByKindUseCase self.upsertStatusMessageUseCase = upsertStatusMessageUseCase } @@ -54,7 +109,7 @@ final class ProfileViewModel: Store { var effects: [SideEffect] = [] switch action { case .onAppear: - effects = [.fetchUserData] + effects = [.fetchUserData, .fetchCompletionMonths] case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .tapResetStatusMessageButton: @@ -64,6 +119,23 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL + case .setCompletionQuarters(let quarters): + state.completionQuarters = quarters + state.selectedQuarterIndex = max(0, quarters.count - 1) + case .moveQuarter(let delta): + let newIndex = state.selectedQuarterIndex + delta + guard state.completionQuarters.indices.contains(newIndex) else { break } + state.selectedQuarterIndex = newIndex + case .toggleHeatmapMetric(let metric): + if state.selectedMetrics.contains(metric), state.selectedMetrics.count == 1 { + break + } + + if state.selectedMetrics.contains(metric) { + state.selectedMetrics.remove(metric) + } else { + state.selectedMetrics.insert(metric) + } case .willUpdateStatusMessage: let message = self.state.statusMessage effects = [.updateStatusMessage(message)] @@ -87,6 +159,17 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } + case .fetchCompletionMonths: + Task { + do { + let todos = try await fetchAllTodos() + let months = makeCompletionMonths(from: todos) + send(.setCompletionQuarters(makeCompletionQuarters(from: months))) + } catch { + let months = makeCompletionMonths(from: []) + send(.setCompletionQuarters(makeCompletionQuarters(from: months))) + } + } case .updateStatusMessage(let message): Task { do { @@ -100,6 +183,139 @@ final class ProfileViewModel: Store { } private extension ProfileViewModel { + func fetchAllTodos() async throws -> [Todo] { + var todos: [Todo] = [] + + for kind in TodoKind.allCases { + var cursor: TodoCursor? + while true { + let page = try await fetchTodosByKindUseCase.execute(kind, cursor: cursor) + todos.append(contentsOf: page.items) + guard let nextCursor = page.nextCursor else { break } + cursor = nextCursor + } + } + + return todos + } + + func makeCompletionMonths(from todos: [Todo]) -> [CompletionMonth] { + let calendar = Calendar.current + var dailyCreatedCount: [Date: Int] = [:] + var dailyCompletedCount: [Date: Int] = [:] + + for todo in todos { + let createdDay = calendar.startOfDay(for: todo.createdAt) + dailyCreatedCount[createdDay, default: 0] += 1 + + if todo.isCompleted { + let completedDay = calendar.startOfDay(for: todo.updatedAt) + dailyCompletedCount[completedDay, default: 0] += 1 + } + } + + let currentMonthStart = startOfMonth(for: Date(), calendar: calendar) + let firstMonthStart: Date + let firstActivityDay = (Array(dailyCreatedCount.keys) + Array(dailyCompletedCount.keys)).min() + if let firstActivityDay { + firstMonthStart = startOfMonth(for: firstActivityDay, calendar: calendar) + } else { + firstMonthStart = currentMonthStart + } + + var monthStarts: [Date] = [] + var cursor = firstMonthStart + while cursor <= currentMonthStart { + monthStarts.append(cursor) + guard let nextMonth = calendar.date(byAdding: .month, value: 1, to: cursor) else { break } + cursor = nextMonth + } + + return monthStarts.map { monthStart in + makeCompletionMonth( + monthStart: monthStart, + createdCounts: dailyCreatedCount, + completedCounts: dailyCompletedCount, + calendar: calendar + ) + } + } + + func makeCompletionQuarters(from months: [CompletionMonth]) -> [CompletionQuarter] { + let calendar = Calendar.current + let grouped = Dictionary(grouping: months) { month -> Date in + quarterStart(for: month.monthStart, calendar: calendar) + } + + return grouped + .map { quarterStart, quarterMonths in + CompletionQuarter( + quarterStart: quarterStart, + months: quarterMonths.sorted { $0.monthStart < $1.monthStart } + ) + } + .sorted { $0.quarterStart < $1.quarterStart } + } + + func makeCompletionMonth( + monthStart: Date, + createdCounts: [Date: Int], + completedCounts: [Date: Int], + calendar: Calendar + ) -> CompletionMonth { + guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), + let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), + let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), + let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { + return CompletionMonth(monthStart: monthStart, weeks: []) + } + + var days: [CompletionDay] = [] + var cursor = firstWeekInterval.start + while cursor < lastWeekInterval.end { + let normalizedDate = calendar.startOfDay(for: cursor) + let isInMonth = calendar.isDate(normalizedDate, equalTo: monthStart, toGranularity: .month) + let createdCount = isInMonth ? (createdCounts[normalizedDate] ?? 0) : 0 + let completedCount = isInMonth ? (completedCounts[normalizedDate] ?? 0) : 0 + days.append( + CompletionDay( + date: normalizedDate, + createdCount: createdCount, + completedCount: completedCount, + isInMonth: isInMonth + ) + ) + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + cursor = nextDay + } + + var weeks: [[CompletionDay]] = [] + var index = 0 + while index < days.count { + let endIndex = min(index + 7, days.count) + weeks.append(Array(days[index.. Date { + guard let monthInterval = calendar.dateInterval(of: .month, for: date) else { + return calendar.startOfDay(for: date) + } + return monthInterval.start + } + + func quarterStart(for date: Date, calendar: Calendar) -> Date { + let month = calendar.component(.month, from: date) + let startMonth = ((month - 1) / 3) * 3 + 1 + var components = calendar.dateComponents([.year], from: date) + components.month = startMonth + components.day = 1 + return calendar.date(from: components) ?? startOfMonth(for: date, calendar: calendar) + } + func setAlert( _ state: inout State, isPresented: Bool diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 029c3b1..d8dcc37 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -310,6 +310,9 @@ }, "베타 테스트 참여" : { + }, + "분기별 활동 히트맵" : { + }, "사용자 설정" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 3733932..00051cd 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -36,6 +36,7 @@ struct MainView: View { } ProfileView(viewModel: ProfileViewModel( fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), + fetchTodosByKindUseCase: container.resolve(FetchTodosByKindUseCase.self), upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self) )) .tabItem { diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 7f6459e..df226e7 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -12,7 +12,6 @@ struct ProfileView: View { @StateObject private var router = NavigationRouter() @Environment(\.diContainer) private var container @FocusState private var focusedOnStatusMessageTextField: Bool - @State private var showDoneBtn: Bool = false var body: some View { NavigationStack(path: $router.path) { @@ -71,6 +70,7 @@ struct ProfileView: View { .transition(.move(edge: .trailing).combined(with: .opacity)) } } + activityHeatmapSection } .padding(.horizontal) } @@ -84,11 +84,6 @@ struct ProfileView: View { } label: { Image(systemName: "gearshape") } - Button(action: { - // TODO: 기능 추가 생각해야함 - }) { - Image(systemName: "plus") - } } } } @@ -122,7 +117,214 @@ struct ProfileView: View { } } + private var activityHeatmapSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("분기별 활동 히트맵") + .font(.headline) + Spacer() + metricSelector + } + + quarterNavigator + + if viewModel.state.completionQuarters.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 140) + } else if let quarter = viewModel.state.selectedQuarter { + QuarterHeatmapView( + quarter: quarter, + selectedMetrics: viewModel.state.selectedMetrics + ) + .padding(.vertical, 6) + } else { + EmptyView() + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(Color(UIColor.secondarySystemGroupedBackground)) + ) + } + + private var metricSelector: some View { + Menu { + ForEach(ProfileViewModel.HeatmapMetric.allCases, id: \.self) { metric in + Button { + viewModel.send(.toggleHeatmapMetric(metric)) + } label: { + HStack { + Text(metric.title) + if viewModel.state.selectedMetrics.contains(metric) { + Image(systemName: "checkmark") + .tint(.blue) + } + } + } + } + } label: { + Text("편집") + .foregroundStyle(.blue) + } + } + + private var quarterNavigator: some View { + HStack { + Button { + viewModel.send(.moveQuarter(-1)) + } label: { + Image(systemName: "chevron.left") + } + .disabled(!viewModel.state.canMoveToPreviousQuarter) + + Spacer() + + Text(quarterTitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Button { + viewModel.send(.moveQuarter(1)) + } label: { + Image(systemName: "chevron.right") + } + .disabled(!viewModel.state.canMoveToNextQuarter) + } + } + + private var quarterTitle: String { + guard let start = viewModel.state.selectedQuarter?.quarterStart else { return "" } + let calendar = Calendar.current + let year = calendar.component(.year, from: start) + let month = calendar.component(.month, from: start) + let quarter = ((month - 1) / 3) + 1 + return "\(year) Q\(quarter)" + } + private enum Path: Hashable { case settings } } + +private struct QuarterHeatmapView: View { + let quarter: ProfileViewModel.CompletionQuarter + let selectedMetrics: Set + + 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, + selectedMetrics: selectedMetrics + ) + if index < months.count - 1 { + Spacer() + } + } + } + } + + @ViewBuilder + private var weekdayLabel: some View { + let labels: [Int: String] = [ + 2: "월", + 4: "수", + 6: "금" + ] + let orderedWeekdays = Array(1...7) + let cellSize: CGFloat = 16 + + VStack(alignment: .leading, spacing: 4) { + ForEach(orderedWeekdays, id: \.self) { weekday in + Group { + if let label = labels[weekday] { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: cellSize, height: cellSize) + } else { + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + } + .padding(.top, 22) + } +} + +private struct MonthCompactHeatmapView: View { + let month: ProfileViewModel.CompletionMonth + let selectedMetrics: Set + 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) { + Text(month.monthStart.formatted(.dateTime.month(.abbreviated))) + .frame(height: cellSize) + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + HStack(spacing: 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) + .fill(fillColor(for: day)) + .frame(width: cellSize, height: cellSize) + .overlay( + RoundedRectangle(cornerRadius: 3) + .stroke( + Color.secondary.opacity((day?.isInMonth ?? false) ? 0.2 : 0), + lineWidth: 0.5 + ) + ) + } + } + } + } + } + } + + private func fillColor(for day: ProfileViewModel.CompletionDay?) -> Color { + guard let day, day.isInMonth else { return .clear } + return Color.accentColor.opacity(opacity(for: dayCount(for: day), max: monthMaxCount)) + } + + private var monthMaxCount: Int { + month.weeks + .flatMap { $0 } + .filter { $0.isInMonth } + .map(dayCount(for:)) + .max() ?? 0 + } + + private func dayCount(for day: ProfileViewModel.CompletionDay) -> Int { + var value = 0 + if selectedMetrics.contains(.created) { + value += day.createdCount + } + if selectedMetrics.contains(.completed) { + value += day.completedCount + } + return value + } + + private func opacity(for count: Int, max: Int) -> Double { + guard count > 0 && max > 0 else { return 0 } + let ratio = Double(count) / Double(max) + return floor(ratio * 10) / 10 + } +} From ed9d1a15b2d62a56d2b92d4be74c73027bf6ec8a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 00:15:44 +0900 Subject: [PATCH 02/28] =?UTF-8?q?fix:=20=EB=85=84=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EB=8B=AC=EB=9D=BC=EC=A7=88=20=EB=95=8C=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9=EA=B0=92=EC=9D=B4=20=EC=9E=90=EB=8F=99=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=EB=90=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index df226e7..6302112 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -16,7 +16,7 @@ struct ProfileView: View { var body: some View { NavigationStack(path: $router.path) { ScrollView { - VStack(alignment: .leading, spacing: 16) { + LazyVStack(alignment: .leading, spacing: 16) { HStack { CacheableImage(url: viewModel.state.avatarURL) .frame(width: 60, height: 60) @@ -72,7 +72,7 @@ struct ProfileView: View { } activityHeatmapSection } - .padding(.horizontal) + .padding(.horizontal, 16) } .frame(maxWidth: .infinity) .background(Color(UIColor.systemGroupedBackground)) From f59a692b7f7c2b295cf763060fec61d695dda947 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 01:45:25 +0900 Subject: [PATCH 03/28] =?UTF-8?q?feat:=20=EB=B6=84=EA=B8=B0=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20Todo=20=EC=83=9D=EC=84=B1=20/=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=ED=9E=88=ED=8A=B8=EB=A7=B5=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EC=97=AC=EC=A3=BC=EB=8F=84=EB=A1=9D=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DomainAssembler.swift | 4 ++ DevLog/Domain/Entity/TodoQuery.swift | 15 +++- .../Todo/FetchTodosByDateRangeUseCase.swift | 12 ++++ .../FetchTodosByDateRangeUseCaseImpl.swift | 26 +++++++ DevLog/Infra/Service/TodoService.swift | 52 ++++++++++---- .../ViewModel/ProfileViewModel.swift | 72 ++++++------------- DevLog/UI/Common/MainView.swift | 2 +- DevLog/UI/Profile/ProfileView.swift | 4 +- 8 files changed, 120 insertions(+), 67 deletions(-) create mode 100644 DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift create mode 100644 DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 67b7044..331f1da 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -63,6 +63,10 @@ private extension DomainAssembler { FetchTodosByKindUseCaseImpl(container.resolve(TodoRepository.self)) } + container.register(FetchTodosByDateRangeUseCase.self) { + FetchTodosByDateRangeUseCaseImpl(container.resolve(TodoRepository.self)) + } + container.register(FetchTodosByKeywordUseCase.self) { FetchTodosByKeywordUseCaseImpl(container.resolve(TodoRepository.self)) } diff --git a/DevLog/Domain/Entity/TodoQuery.swift b/DevLog/Domain/Entity/TodoQuery.swift index defa482..6bc4f55 100644 --- a/DevLog/Domain/Entity/TodoQuery.swift +++ b/DevLog/Domain/Entity/TodoQuery.swift @@ -5,21 +5,32 @@ // Created by opfic on 2/21/26. // +import Foundation + struct TodoQuery { let kind: TodoKind? let keyword: String? let isPinned: Bool? - let pageSize: Int + let createdAtFrom: Date? + let createdAtTo: Date? + let createdAtDescending: Bool + let pageSize: Int? init( kind: TodoKind? = nil, keyword: String? = nil, isPinned: Bool? = nil, - pageSize: Int = 20 + createdAtFrom: Date? = nil, + createdAtTo: Date? = nil, + createdAtDescending: Bool = true, + pageSize: Int? = 20 ) { self.kind = kind self.keyword = keyword self.isPinned = isPinned + self.createdAtFrom = createdAtFrom + self.createdAtTo = createdAtTo + self.createdAtDescending = createdAtDescending self.pageSize = pageSize } } diff --git a/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift new file mode 100644 index 0000000..86f739f --- /dev/null +++ b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCase.swift @@ -0,0 +1,12 @@ +// +// FetchTodosByDateRangeUseCase.swift +// DevLog +// +// Created by opfic on 3/1/26. +// + +import Foundation + +protocol FetchTodosByDateRangeUseCase { + func execute(from startDate: Date, to endDate: Date) async throws -> [Todo] +} diff --git a/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift new file mode 100644 index 0000000..181d357 --- /dev/null +++ b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift @@ -0,0 +1,26 @@ +// +// FetchTodosByDateRangeUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/1/26. +// + +import Foundation + +final class FetchTodosByDateRangeUseCaseImpl: FetchTodosByDateRangeUseCase { + private let repository: TodoRepository + + init(_ repository: TodoRepository) { + self.repository = repository + } + + func execute(from startDate: Date, to endDate: Date) async throws -> [Todo] { + let query = TodoQuery( + createdAtFrom: startDate, + createdAtTo: endDate, + pageSize: nil + ) + let page = try await repository.fetchTodos(query, cursor: nil) + return page.items + } +} diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 98668d9..6573897 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -23,12 +23,16 @@ final class TodoService { let logMessage = "Fetching todo page: kind=\(String(describing: query.kind)), " + "keyword=\(trimmedKeyword), " + "pinned=\(String(describing: query.isPinned)), " + + "createdAtFrom=\(String(describing: query.createdAtFrom)), " + + "createdAtTo=\(String(describing: query.createdAtTo)), " + + "createdAtDescending=\(query.createdAtDescending), " + + "pageSize=\(String(describing: query.pageSize)), " + "cursor=\(String(describing: cursor))" logger.info(logMessage) var firestoreQuery: Query = store .collection("users/\(uid)/todoLists/") - .order(by: "createdAt", descending: true) + .order(by: "createdAt", descending: query.createdAtDescending) .order(by: FieldPath.documentID()) if let kind = query.kind { @@ -39,6 +43,20 @@ final class TodoService { firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: isPinned) } + if let createdAtFrom = query.createdAtFrom { + firestoreQuery = firestoreQuery.whereField( + "createdAt", + isGreaterThanOrEqualTo: Timestamp(date: createdAtFrom) + ) + } + + if let createdAtTo = query.createdAtTo { + firestoreQuery = firestoreQuery.whereField( + "createdAt", + isLessThan: Timestamp(date: createdAtTo) + ) + } + if trimmedKeyword.isEmpty { if let cursor { firestoreQuery = firestoreQuery.start(after: [ @@ -47,21 +65,31 @@ final class TodoService { ]) } - let snapshot = try await firestoreQuery - .limit(to: query.pageSize) - .getDocuments() + let snapshot: QuerySnapshot + if let pageSize = query.pageSize { + snapshot = try await firestoreQuery + .limit(to: pageSize) + .getDocuments() + } else { + snapshot = try await firestoreQuery.getDocuments() + } let items = snapshot.documents.compactMap { makeResponse(from: $0) } - let nextCursor: TodoCursorDTO? = snapshot.documents.last.flatMap { document in - guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else { - return nil + let nextCursor: TodoCursorDTO? + if query.pageSize == nil { + nextCursor = nil + } else { + nextCursor = snapshot.documents.last.flatMap { document in + guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else { + return nil + } + + return TodoCursorDTO( + createdAt: createdAt.dateValue(), + documentID: document.documentID + ) } - - return TodoCursorDTO( - createdAt: createdAt.dateValue(), - documentID: document.documentID - ) } return TodoPageResponse(items: items, nextCursor: nextCursor) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 4e1f202..2f76519 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -91,16 +91,16 @@ final class ProfileViewModel: Store { @Published private(set) var state = State() private let fetchUserDataUseCase: FetchUserDataUseCase - private let fetchTodosByKindUseCase: FetchTodosByKindUseCase + private let fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase private let upsertStatusMessageUseCase: UpsertStatusMessageUseCase init( fetchUserDataUseCase: FetchUserDataUseCase, - fetchTodosByKindUseCase: FetchTodosByKindUseCase, + fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase, upsertStatusMessageUseCase: UpsertStatusMessageUseCase ) { self.fetchUserDataUseCase = fetchUserDataUseCase - self.fetchTodosByKindUseCase = fetchTodosByKindUseCase + self.fetchTodosByDateRangeUseCase = fetchTodosByDateRangeUseCase self.upsertStatusMessageUseCase = upsertStatusMessageUseCase } @@ -161,13 +161,17 @@ final class ProfileViewModel: Store { } case .fetchCompletionMonths: Task { + let calendar = Calendar.current + let currentQuarterStart = quarterStart(for: Date(), calendar: calendar) do { let todos = try await fetchAllTodos() - let months = makeCompletionMonths(from: todos) - send(.setCompletionQuarters(makeCompletionQuarters(from: months))) + let months = makeCompletionMonths(from: todos, quarterStart: currentQuarterStart) + let quarter = CompletionQuarter(quarterStart: currentQuarterStart, months: months) + send(.setCompletionQuarters([quarter])) } catch { - let months = makeCompletionMonths(from: []) - send(.setCompletionQuarters(makeCompletionQuarters(from: months))) + let months = makeCompletionMonths(from: [], quarterStart: currentQuarterStart) + let quarter = CompletionQuarter(quarterStart: currentQuarterStart, months: months) + send(.setCompletionQuarters([quarter])) } } case .updateStatusMessage(let message): @@ -184,22 +188,19 @@ final class ProfileViewModel: Store { private extension ProfileViewModel { func fetchAllTodos() async throws -> [Todo] { - var todos: [Todo] = [] - - for kind in TodoKind.allCases { - var cursor: TodoCursor? - while true { - let page = try await fetchTodosByKindUseCase.execute(kind, cursor: cursor) - todos.append(contentsOf: page.items) - guard let nextCursor = page.nextCursor else { break } - cursor = nextCursor - } + let calendar = Calendar.current + let currentQuarterStart = quarterStart(for: Date(), calendar: calendar) + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: currentQuarterStart) else { + return [] } - return todos + return try await fetchTodosByDateRangeUseCase.execute( + from: currentQuarterStart, + to: nextQuarterStart + ) } - func makeCompletionMonths(from todos: [Todo]) -> [CompletionMonth] { + func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [CompletionMonth] { let calendar = Calendar.current var dailyCreatedCount: [Date: Int] = [:] var dailyCompletedCount: [Date: Int] = [:] @@ -214,21 +215,8 @@ private extension ProfileViewModel { } } - let currentMonthStart = startOfMonth(for: Date(), calendar: calendar) - let firstMonthStart: Date - let firstActivityDay = (Array(dailyCreatedCount.keys) + Array(dailyCompletedCount.keys)).min() - if let firstActivityDay { - firstMonthStart = startOfMonth(for: firstActivityDay, calendar: calendar) - } else { - firstMonthStart = currentMonthStart - } - - var monthStarts: [Date] = [] - var cursor = firstMonthStart - while cursor <= currentMonthStart { - monthStarts.append(cursor) - guard let nextMonth = calendar.date(byAdding: .month, value: 1, to: cursor) else { break } - cursor = nextMonth + let monthStarts = (0..<3).compactMap { + calendar.date(byAdding: .month, value: $0, to: quarterStart) } return monthStarts.map { monthStart in @@ -241,22 +229,6 @@ private extension ProfileViewModel { } } - func makeCompletionQuarters(from months: [CompletionMonth]) -> [CompletionQuarter] { - let calendar = Calendar.current - let grouped = Dictionary(grouping: months) { month -> Date in - quarterStart(for: month.monthStart, calendar: calendar) - } - - return grouped - .map { quarterStart, quarterMonths in - CompletionQuarter( - quarterStart: quarterStart, - months: quarterMonths.sorted { $0.monthStart < $1.monthStart } - ) - } - .sorted { $0.quarterStart < $1.quarterStart } - } - func makeCompletionMonth( monthStart: Date, createdCounts: [Date: Int], diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 00051cd..afe78f2 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -36,7 +36,7 @@ struct MainView: View { } ProfileView(viewModel: ProfileViewModel( fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), - fetchTodosByKindUseCase: container.resolve(FetchTodosByKindUseCase.self), + fetchTodosByDateRangeUseCase: container.resolve(FetchTodosByDateRangeUseCase.self), upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self) )) .tabItem { diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 6302112..6065224 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -300,7 +300,7 @@ private struct MonthCompactHeatmapView: View { private func fillColor(for day: ProfileViewModel.CompletionDay?) -> Color { guard let day, day.isInMonth else { return .clear } - return Color.accentColor.opacity(opacity(for: dayCount(for: day), max: monthMaxCount)) + return Color.blue.opacity(opacity(for: dayCount(for: day), max: monthMaxCount)) } private var monthMaxCount: Int { @@ -323,7 +323,7 @@ private struct MonthCompactHeatmapView: View { } private func opacity(for count: Int, max: Int) -> Double { - guard count > 0 && max > 0 else { return 0 } + guard 0 < count && 0 < max else { return 0 } let ratio = Double(count) / Double(max) return floor(ratio * 10) / 10 } From 29afda99cc28136996bdb2b024687d7fa014d4d2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 01:59:09 +0900 Subject: [PATCH 04/28] =?UTF-8?q?fix:=20=EB=B6=84=EA=B8=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=EC=9D=B4=20=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 100 +++++++++++++----- DevLog/UI/Profile/ProfileView.swift | 6 +- 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 2f76519..45f5e59 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -45,8 +45,8 @@ final class ProfileViewModel: Store { var email: String = "" var statusMessage: String = "" var avatarURL: URL? - var completionQuarters: [CompletionQuarter] = [] - var selectedQuarterIndex: Int = 0 + var selectedQuarterStart: Date? + var completionQuarterCache: [Date: CompletionQuarter] = [:] var selectedMetrics: Set = [.created, .completed] var showDoneButton: Bool = false var showAlert: Bool = false @@ -57,16 +57,36 @@ final class ProfileViewModel: Store { } var selectedQuarter: CompletionQuarter? { - guard completionQuarters.indices.contains(selectedQuarterIndex) else { return nil } - return completionQuarters[selectedQuarterIndex] + guard let selectedQuarterStart else { return nil } + return completionQuarterCache[selectedQuarterStart] } var canMoveToPreviousQuarter: Bool { - selectedQuarterIndex > 0 + guard let selectedQuarterStart else { return false } + let calendar = Calendar.current + guard let previousQuarterStart = calendar.date(byAdding: .month, value: -3, to: selectedQuarterStart) else { + return false + } + let today = calendar.startOfDay(for: Date()) + return Self.canMove(to: previousQuarterStart, calendar: calendar, today: today) } var canMoveToNextQuarter: Bool { - selectedQuarterIndex < completionQuarters.count - 1 + guard let selectedQuarterStart else { return false } + let calendar = Calendar.current + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: selectedQuarterStart) else { + return false + } + let today = calendar.startOfDay(for: Date()) + return Self.canMove(to: nextQuarterStart, calendar: calendar, today: today) + } + + private static 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 } } @@ -76,7 +96,7 @@ final class ProfileViewModel: Store { case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) - case setCompletionQuarters([CompletionQuarter]) + case setCompletionQuarter(CompletionQuarter) case moveQuarter(Int) case toggleHeatmapMetric(HeatmapMetric) case updateStatusMessage(String) @@ -85,7 +105,7 @@ final class ProfileViewModel: Store { enum SideEffect { case fetchUserData - case fetchCompletionMonths + case fetchCompletionQuarter(Date) case updateStatusMessage(String) } @@ -109,7 +129,15 @@ final class ProfileViewModel: Store { var effects: [SideEffect] = [] switch action { case .onAppear: - effects = [.fetchUserData, .fetchCompletionMonths] + let calendar = Calendar.current + if state.selectedQuarterStart == nil { + state.selectedQuarterStart = quarterStart(for: Date(), calendar: calendar) + } + effects = [.fetchUserData] + if let selectedQuarterStart = state.selectedQuarterStart, + state.completionQuarterCache[selectedQuarterStart] == nil { + effects.append(.fetchCompletionQuarter(selectedQuarterStart)) + } case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .tapResetStatusMessageButton: @@ -119,13 +147,22 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL - case .setCompletionQuarters(let quarters): - state.completionQuarters = quarters - state.selectedQuarterIndex = max(0, quarters.count - 1) + case .setCompletionQuarter(let quarter): + state.completionQuarterCache[quarter.quarterStart] = quarter case .moveQuarter(let delta): - let newIndex = state.selectedQuarterIndex + delta - guard state.completionQuarters.indices.contains(newIndex) else { break } - state.selectedQuarterIndex = newIndex + guard let selectedQuarterStart = state.selectedQuarterStart else { break } + let calendar = Calendar.current + let monthDelta = 3 * delta + guard let nextQuarterStart = calendar.date(byAdding: .month, 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 + if state.completionQuarterCache[nextQuarterStart] == nil { + effects = [.fetchCompletionQuarter(nextQuarterStart)] + } case .toggleHeatmapMetric(let metric): if state.selectedMetrics.contains(metric), state.selectedMetrics.count == 1 { break @@ -159,19 +196,17 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } - case .fetchCompletionMonths: + case .fetchCompletionQuarter(let quarterStart): Task { - let calendar = Calendar.current - let currentQuarterStart = quarterStart(for: Date(), calendar: calendar) do { - let todos = try await fetchAllTodos() - let months = makeCompletionMonths(from: todos, quarterStart: currentQuarterStart) - let quarter = CompletionQuarter(quarterStart: currentQuarterStart, months: months) - send(.setCompletionQuarters([quarter])) + let todos = try await fetchQuarterTodos(from: quarterStart) + let months = makeCompletionMonths(from: todos, quarterStart: quarterStart) + let quarter = CompletionQuarter(quarterStart: quarterStart, months: months) + send(.setCompletionQuarter(quarter)) } catch { - let months = makeCompletionMonths(from: [], quarterStart: currentQuarterStart) - let quarter = CompletionQuarter(quarterStart: currentQuarterStart, months: months) - send(.setCompletionQuarters([quarter])) + let months = makeCompletionMonths(from: [], quarterStart: quarterStart) + let quarter = CompletionQuarter(quarterStart: quarterStart, months: months) + send(.setCompletionQuarter(quarter)) } } case .updateStatusMessage(let message): @@ -187,19 +222,26 @@ final class ProfileViewModel: Store { } private extension ProfileViewModel { - func fetchAllTodos() async throws -> [Todo] { + func fetchQuarterTodos(from quarterStart: Date) async throws -> [Todo] { let calendar = Calendar.current - let currentQuarterStart = quarterStart(for: Date(), calendar: calendar) - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: currentQuarterStart) else { + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { return [] } return try await fetchTodosByDateRangeUseCase.execute( - from: currentQuarterStart, + from: quarterStart, to: nextQuarterStart ) } + 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 makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [CompletionMonth] { let calendar = Calendar.current var dailyCreatedCount: [Date: Int] = [:] diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 6065224..d14431c 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -128,7 +128,7 @@ struct ProfileView: View { quarterNavigator - if viewModel.state.completionQuarters.isEmpty { + if viewModel.state.selectedQuarter == nil { ProgressView() .frame(maxWidth: .infinity, minHeight: 140) } else if let quarter = viewModel.state.selectedQuarter { @@ -196,7 +196,7 @@ struct ProfileView: View { } private var quarterTitle: String { - guard let start = viewModel.state.selectedQuarter?.quarterStart else { return "" } + guard let start = viewModel.state.selectedQuarterStart else { return "" } let calendar = Calendar.current let year = calendar.component(.year, from: start) let month = calendar.component(.month, from: start) @@ -287,7 +287,7 @@ private struct MonthCompactHeatmapView: View { .overlay( RoundedRectangle(cornerRadius: 3) .stroke( - Color.secondary.opacity((day?.isInMonth ?? false) ? 0.2 : 0), + Color.secondary.opacity((day?.isInMonth ?? false) ? 0.5 : 0), lineWidth: 0.5 ) ) From fe8d453316063ccc23ee9fcb4ed44b8b894d24f7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 02:02:36 +0900 Subject: [PATCH 05/28] =?UTF-8?q?ui:=20=ED=85=8C=EB=91=90=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EB=B6=84=ED=95=98=EB=8A=94=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=B1=84=EC=9A=B0=EA=B8=B0=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileView.swift | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index d14431c..90f12aa 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -284,13 +284,6 @@ private struct MonthCompactHeatmapView: View { RoundedRectangle(cornerRadius: 3) .fill(fillColor(for: day)) .frame(width: cellSize, height: cellSize) - .overlay( - RoundedRectangle(cornerRadius: 3) - .stroke( - Color.secondary.opacity((day?.isInMonth ?? false) ? 0.5 : 0), - lineWidth: 0.5 - ) - ) } } } @@ -300,7 +293,11 @@ private struct MonthCompactHeatmapView: View { private func fillColor(for day: ProfileViewModel.CompletionDay?) -> Color { guard let day, day.isInMonth else { return .clear } - return Color.blue.opacity(opacity(for: dayCount(for: day), max: monthMaxCount)) + let count = dayCount(for: day) + if count == 0 { + return Color(UIColor.systemGray5) + } + return Color.blue.opacity(opacity(for: count, max: monthMaxCount)) } private var monthMaxCount: Int { From c7e7c5c2f24a2fc5ee40c05c54944d0b97c6fba5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 02:29:05 +0900 Subject: [PATCH 06/28] =?UTF-8?q?feat:=20UserDefaults=EB=A1=9C=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=20=EC=84=A4=EC=A0=95=EA=B0=92=EC=9D=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EC=97=AC=20=EC=98=81=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DomainAssembler.swift | 8 ++++ .../UserPreferencesRepositoryImpl.swift | 9 ++++ .../Protocol/UserPreferencesRepository.swift | 3 ++ ...chProfileHeatmapActivityTypesUseCase.swift | 10 ++++ ...ofileHeatmapActivityTypesUseCaseImpl.swift | 18 +++++++ ...teProfileHeatmapActivityTypesUseCase.swift | 10 ++++ ...ofileHeatmapActivityTypesUseCaseImpl.swift | 18 +++++++ .../ViewModel/ProfileViewModel.swift | 48 ++++++++++++++----- DevLog/UI/Common/MainView.swift | 4 +- DevLog/UI/Profile/ProfileView.swift | 24 +++++----- 10 files changed, 127 insertions(+), 25 deletions(-) create mode 100644 DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift create mode 100644 DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 331f1da..760b23e 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -158,5 +158,13 @@ private extension DomainAssembler { container.register(UpdatePushNotificationQueryUseCase.self) { UpdatePushNotificationQueryUseCaseImpl(container.resolve(UserPreferencesRepository.self)) } + + container.register(FetchProfileHeatmapActivityTypesUseCase.self) { + FetchProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } + + container.register(UpdateProfileHeatmapActivityTypesUseCase.self) { + UpdateProfileHeatmapActivityTypesUseCaseImpl(container.resolve(UserPreferencesRepository.self)) + } } } diff --git a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift index d4afa39..95bde86 100644 --- a/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift +++ b/DevLog/Data/Repository/UserPreferencesRepositoryImpl.swift @@ -16,6 +16,7 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { static let pushSortOrder = "PushNotification.sortOption" static let pushTimeFilter = "PushNotification.timeFilter" static let pushUnreadOnly = "PushNotification.showUnreadOnly" + static let profileHeatmapActivityTypes = "Profile.heatmap.activityTypes" } private let store: UserDefaultsStore @@ -92,4 +93,12 @@ final class UserPreferencesRepositoryImpl: UserPreferencesRepository { func setPushNotificationUnreadOnly(_ value: Bool) { store.setBool(value, forKey: Key.pushUnreadOnly) } + + func profileHeatmapActivityTypes() -> [String] { + store.stringArray(forKey: Key.profileHeatmapActivityTypes) + } + + func setProfileHeatmapActivityTypes(_ activityTypes: [String]) { + store.setStringArray(activityTypes, forKey: Key.profileHeatmapActivityTypes) + } } diff --git a/DevLog/Domain/Protocol/UserPreferencesRepository.swift b/DevLog/Domain/Protocol/UserPreferencesRepository.swift index 850593d..ba27fb4 100644 --- a/DevLog/Domain/Protocol/UserPreferencesRepository.swift +++ b/DevLog/Domain/Protocol/UserPreferencesRepository.swift @@ -27,4 +27,7 @@ protocol UserPreferencesRepository { func pushNotificationUnreadOnly() -> Bool func setPushNotificationUnreadOnly(_ value: Bool) + + func profileHeatmapActivityTypes() -> [String] + func setProfileHeatmapActivityTypes(_ activityTypes: [String]) } diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift new file mode 100644 index 0000000..65ee937 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchProfileHeatmapActivityTypesUseCase.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +protocol FetchProfileHeatmapActivityTypesUseCase { + func execute() -> [String] +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift new file mode 100644 index 0000000..e7df167 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/FetchProfileHeatmapActivityTypesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchProfileHeatmapActivityTypesUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +final class FetchProfileHeatmapActivityTypesUseCaseImpl: FetchProfileHeatmapActivityTypesUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute() -> [String] { + repository.profileHeatmapActivityTypes() + } +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift new file mode 100644 index 0000000..4f85f28 --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCase.swift @@ -0,0 +1,10 @@ +// +// UpdateProfileHeatmapActivityTypesUseCase.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +protocol UpdateProfileHeatmapActivityTypesUseCase { + func execute(_ activityTypes: [String]) +} diff --git a/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift new file mode 100644 index 0000000..2a43f1f --- /dev/null +++ b/DevLog/Domain/UseCase/UserPreferences/Profile/UpdateProfileHeatmapActivityTypesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UpdateProfileHeatmapActivityTypesUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +final class UpdateProfileHeatmapActivityTypesUseCaseImpl: UpdateProfileHeatmapActivityTypesUseCase { + private let repository: UserPreferencesRepository + + init(_ repository: UserPreferencesRepository) { + self.repository = repository + } + + func execute(_ activityTypes: [String]) { + repository.setProfileHeatmapActivityTypes(activityTypes) + } +} diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 45f5e59..3631dc4 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -8,7 +8,7 @@ import Foundation final class ProfileViewModel: Store { - enum HeatmapMetric: String, CaseIterable, Hashable { + enum ActivityType: String, CaseIterable, Hashable { case created case completed @@ -47,7 +47,7 @@ final class ProfileViewModel: Store { var avatarURL: URL? var selectedQuarterStart: Date? var completionQuarterCache: [Date: CompletionQuarter] = [:] - var selectedMetrics: Set = [.created, .completed] + var selectedActivityTypes: Set = [.created, .completed] var showDoneButton: Bool = false var showAlert: Bool = false var alertTitle: String = "" @@ -98,7 +98,7 @@ final class ProfileViewModel: Store { case fetchUserData(UserProfile) case setCompletionQuarter(CompletionQuarter) case moveQuarter(Int) - case toggleHeatmapMetric(HeatmapMetric) + case toggleActivityType(ActivityType) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) } @@ -107,21 +107,28 @@ final class ProfileViewModel: Store { case fetchUserData case fetchCompletionQuarter(Date) case updateStatusMessage(String) + case updateHeatmapActivityTypes(Set) } @Published private(set) var state = State() private let fetchUserDataUseCase: FetchUserDataUseCase private let fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase private let upsertStatusMessageUseCase: UpsertStatusMessageUseCase + private let fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase + private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase init( fetchUserDataUseCase: FetchUserDataUseCase, fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase, - upsertStatusMessageUseCase: UpsertStatusMessageUseCase + upsertStatusMessageUseCase: UpsertStatusMessageUseCase, + fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase, + updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase ) { self.fetchUserDataUseCase = fetchUserDataUseCase self.fetchTodosByDateRangeUseCase = fetchTodosByDateRangeUseCase self.upsertStatusMessageUseCase = upsertStatusMessageUseCase + self.fetchHeatmapActivityTypesUseCase = fetchHeatmapActivityTypesUseCase + self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase } func reduce(with action: Action) -> [SideEffect] { @@ -133,6 +140,11 @@ final class ProfileViewModel: Store { if state.selectedQuarterStart == nil { state.selectedQuarterStart = quarterStart(for: Date(), calendar: calendar) } + let rawValues = fetchHeatmapActivityTypesUseCase.execute() + let settings = normalizeActivityTypes(rawValues) + if !settings.isEmpty { + state.selectedActivityTypes = settings + } effects = [.fetchUserData] if let selectedQuarterStart = state.selectedQuarterStart, state.completionQuarterCache[selectedQuarterStart] == nil { @@ -153,9 +165,11 @@ final class ProfileViewModel: Store { guard let selectedQuarterStart = state.selectedQuarterStart else { break } let calendar = Calendar.current let monthDelta = 3 * delta - guard let nextQuarterStart = calendar.date(byAdding: .month, value: monthDelta, to: selectedQuarterStart) else { - break - } + guard let nextQuarterStart = calendar.date( + byAdding: .month, + value: monthDelta, + to: selectedQuarterStart + ) else { break } let today = calendar.startOfDay(for: Date()) guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } @@ -163,16 +177,17 @@ final class ProfileViewModel: Store { if state.completionQuarterCache[nextQuarterStart] == nil { effects = [.fetchCompletionQuarter(nextQuarterStart)] } - case .toggleHeatmapMetric(let metric): - if state.selectedMetrics.contains(metric), state.selectedMetrics.count == 1 { + case .toggleActivityType(let activityType): + if state.selectedActivityTypes.contains(activityType), state.selectedActivityTypes.count == 1 { break } - if state.selectedMetrics.contains(metric) { - state.selectedMetrics.remove(metric) + if state.selectedActivityTypes.contains(activityType) { + state.selectedActivityTypes.remove(activityType) } else { - state.selectedMetrics.insert(metric) + state.selectedActivityTypes.insert(activityType) } + effects = [.updateHeatmapActivityTypes(state.selectedActivityTypes)] case .willUpdateStatusMessage: let message = self.state.statusMessage effects = [.updateStatusMessage(message)] @@ -217,6 +232,11 @@ final class ProfileViewModel: Store { send(.setAlert(true)) } } + case .updateHeatmapActivityTypes(let activityTypes): + let rawValues = ActivityType.allCases + .filter { activityTypes.contains($0) } + .map(\.rawValue) + updateHeatmapActivityTypesUseCase.execute(rawValues) } } } @@ -242,6 +262,10 @@ private extension ProfileViewModel { return interval.contains(today) || quarterEnd <= today } + func normalizeActivityTypes(_ rawValues: [String]) -> Set { + Set(rawValues.compactMap(ActivityType.init(rawValue:))) + } + func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [CompletionMonth] { let calendar = Calendar.current var dailyCreatedCount: [Date: Int] = [:] diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index afe78f2..049cd3d 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -37,7 +37,9 @@ struct MainView: View { ProfileView(viewModel: ProfileViewModel( fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), fetchTodosByDateRangeUseCase: container.resolve(FetchTodosByDateRangeUseCase.self), - upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self) + upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), + fetchHeatmapActivityTypesUseCase: container.resolve(FetchProfileHeatmapActivityTypesUseCase.self), + updateHeatmapActivityTypesUseCase: container.resolve(UpdateProfileHeatmapActivityTypesUseCase.self) )) .tabItem { Image(systemName: "person.crop.circle.fill") diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 90f12aa..d92960b 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -123,7 +123,7 @@ struct ProfileView: View { Text("분기별 활동 히트맵") .font(.headline) Spacer() - metricSelector + activityTypeSelector } quarterNavigator @@ -134,7 +134,7 @@ struct ProfileView: View { } else if let quarter = viewModel.state.selectedQuarter { QuarterHeatmapView( quarter: quarter, - selectedMetrics: viewModel.state.selectedMetrics + selectedActivityTypes: viewModel.state.selectedActivityTypes ) .padding(.vertical, 6) } else { @@ -148,15 +148,15 @@ struct ProfileView: View { ) } - private var metricSelector: some View { + private var activityTypeSelector: some View { Menu { - ForEach(ProfileViewModel.HeatmapMetric.allCases, id: \.self) { metric in + ForEach(ProfileViewModel.ActivityType.allCases, id: \.self) { activityType in Button { - viewModel.send(.toggleHeatmapMetric(metric)) + viewModel.send(.toggleActivityType(activityType)) } label: { HStack { - Text(metric.title) - if viewModel.state.selectedMetrics.contains(metric) { + Text(activityType.title) + if viewModel.state.selectedActivityTypes.contains(activityType) { Image(systemName: "checkmark") .tint(.blue) } @@ -211,7 +211,7 @@ struct ProfileView: View { private struct QuarterHeatmapView: View { let quarter: ProfileViewModel.CompletionQuarter - let selectedMetrics: Set + let selectedActivityTypes: Set var body: some View { HStack(alignment: .top, spacing: 0) { @@ -221,7 +221,7 @@ private struct QuarterHeatmapView: View { ForEach(Array(zip(months.indices, months)), id: \.1) { index, month in MonthCompactHeatmapView( month: month, - selectedMetrics: selectedMetrics + selectedActivityTypes: selectedActivityTypes ) if index < months.count - 1 { Spacer() @@ -261,7 +261,7 @@ private struct QuarterHeatmapView: View { private struct MonthCompactHeatmapView: View { let month: ProfileViewModel.CompletionMonth - let selectedMetrics: Set + let selectedActivityTypes: Set private let orderedWeekdays = Array(1...7) private let cellSize: CGFloat = 16 private let cellSpacing: CGFloat = 4 @@ -310,10 +310,10 @@ private struct MonthCompactHeatmapView: View { private func dayCount(for day: ProfileViewModel.CompletionDay) -> Int { var value = 0 - if selectedMetrics.contains(.created) { + if selectedActivityTypes.contains(.created) { value += day.createdCount } - if selectedMetrics.contains(.completed) { + if selectedActivityTypes.contains(.completed) { value += day.completedCount } return value From 5bab2b97ec83fc33b7ce20c90d9ff2132d84e8ce Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 03:32:50 +0900 Subject: [PATCH 07/28] =?UTF-8?q?ui:=20=EB=A7=B5=EC=9D=84=20=ED=83=AD?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20=ED=95=B4=EB=8B=B9=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EC=97=90=20=ED=95=B4=EB=8B=B9=ED=95=98=EB=8A=94=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Profile/ProfileActivityType.swift | 20 +++ .../Profile/ProfileCompletionDay.swift | 15 ++ .../Profile/ProfileCompletionMonth.swift | 14 ++ .../Profile/ProfileCompletionQuarter.swift | 15 ++ .../Profile/ProfileSelectedDayActivity.swift | 23 +++ .../ViewModel/ProfileViewModel.swift | 109 ++++++------ DevLog/Resource/Localizable.xcstrings | 3 + DevLog/UI/Profile/ProfileHeatmapView.swift | 162 ++++++++++++++++++ DevLog/UI/Profile/ProfileView.swift | 160 ++++++----------- 9 files changed, 356 insertions(+), 165 deletions(-) create mode 100644 DevLog/Presentation/Structure/Profile/ProfileActivityType.swift create mode 100644 DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift create mode 100644 DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift create mode 100644 DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift create mode 100644 DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift create mode 100644 DevLog/UI/Profile/ProfileHeatmapView.swift diff --git a/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift b/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift new file mode 100644 index 0000000..f2ef586 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileActivityType.swift @@ -0,0 +1,20 @@ +// +// ProfileActivityType.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +enum ProfileActivityType: String, CaseIterable, Hashable { + case created + case completed + + var title: String { + switch self { + case .created: return "생성" + case .completed: return "완료" + } + } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift new file mode 100644 index 0000000..ee6bd8f --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionDay.swift @@ -0,0 +1,15 @@ +// +// ProfileCompletionDay.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileCompletionDay: Hashable { + let date: Date + let createdCount: Int + let completedCount: Int + let isInMonth: Bool +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift new file mode 100644 index 0000000..a567540 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionMonth.swift @@ -0,0 +1,14 @@ +// +// ProfileCompletionMonth.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileCompletionMonth: Identifiable, Hashable { + var id: Date { monthStart } + let monthStart: Date + let weeks: [[ProfileCompletionDay]] +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift new file mode 100644 index 0000000..c8590c4 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift @@ -0,0 +1,15 @@ +// +// ProfileCompletionQuarter.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileCompletionQuarter: Identifiable, Hashable { + let quarterStart: Date + let months: [ProfileCompletionMonth] + + var id: Date { quarterStart } +} diff --git a/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift new file mode 100644 index 0000000..fb56c40 --- /dev/null +++ b/DevLog/Presentation/Structure/Profile/ProfileSelectedDayActivity.swift @@ -0,0 +1,23 @@ +// +// ProfileSelectedDayActivity.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import Foundation + +struct ProfileSelectedDayActivity: Identifiable, Hashable { + let todo: Todo + let showsCreated: Bool + let showsCompleted: Bool + + var id: String { todo.id } + + var activityLabel: String { + if showsCreated && showsCompleted { + return "생성/완료" + } + return showsCreated ? "생성" : "완료" + } +} diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 3631dc4..7780175 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -8,46 +8,16 @@ import Foundation final class ProfileViewModel: Store { - enum ActivityType: String, CaseIterable, Hashable { - case created - case completed - - var title: String { - switch self { - case .created: return "생성" - case .completed: return "완료" - } - } - } - - struct CompletionDay: Hashable { - let date: Date - let createdCount: Int - let completedCount: Int - let isInMonth: Bool - } - - struct CompletionMonth: Identifiable, Hashable { - var id: Date { monthStart } - let monthStart: Date - let weeks: [[CompletionDay]] - } - - struct CompletionQuarter: Identifiable, Hashable { - let quarterStart: Date - let months: [CompletionMonth] - - var id: Date { quarterStart } - } - struct State { var name: String = "" var email: String = "" var statusMessage: String = "" var avatarURL: URL? var selectedQuarterStart: Date? - var completionQuarterCache: [Date: CompletionQuarter] = [:] - var selectedActivityTypes: Set = [.created, .completed] + var completionQuarterCache: [Date: ProfileCompletionQuarter] = [:] + var quarterTodosCache: [Date: [Todo]] = [:] + var selectedActivityTypes: Set = [.created, .completed] + var selectedDay: ProfileCompletionDay? var showDoneButton: Bool = false var showAlert: Bool = false var alertTitle: String = "" @@ -56,11 +26,33 @@ final class ProfileViewModel: Store { !statusMessage.isEmpty && showDoneButton } - var selectedQuarter: CompletionQuarter? { + var selectedQuarter: ProfileCompletionQuarter? { guard let selectedQuarterStart else { return nil } return completionQuarterCache[selectedQuarterStart] } + var selectedDayActivities: [ProfileSelectedDayActivity] { + guard let selectedDay, + let selectedQuarterStart, + let todos = quarterTodosCache[selectedQuarterStart] else { return [] } + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: selectedDay.date) + + return todos.compactMap { todo in + let isCreated = selectedActivityTypes.contains(.created) + && calendar.startOfDay(for: todo.createdAt) == dayStart + let isCompleted = selectedActivityTypes.contains(.completed) + && todo.isCompleted + && calendar.startOfDay(for: todo.updatedAt) == dayStart + guard isCreated || isCompleted else { return nil } + return ProfileSelectedDayActivity( + todo: todo, + showsCreated: isCreated, + showsCompleted: isCompleted + ) + } + } + var canMoveToPreviousQuarter: Bool { guard let selectedQuarterStart else { return false } let calendar = Calendar.current @@ -96,9 +88,10 @@ final class ProfileViewModel: Store { case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) - case setCompletionQuarter(CompletionQuarter) + case setCompletionQuarter(ProfileCompletionQuarter, [Todo]) case moveQuarter(Int) - case toggleActivityType(ActivityType) + case toggleActivityType(ProfileActivityType) + case selectDay(ProfileCompletionDay?) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) } @@ -107,7 +100,7 @@ final class ProfileViewModel: Store { case fetchUserData case fetchCompletionQuarter(Date) case updateStatusMessage(String) - case updateHeatmapActivityTypes(Set) + case updateHeatmapActivityTypes(Set) } @Published private(set) var state = State() @@ -131,6 +124,7 @@ final class ProfileViewModel: Store { self.updateHeatmapActivityTypesUseCase = updateHeatmapActivityTypesUseCase } + // swiftlint:disable cyclomatic_complexity func reduce(with action: Action) -> [SideEffect] { var state = self.state var effects: [SideEffect] = [] @@ -159,8 +153,15 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL - case .setCompletionQuarter(let quarter): + case .setCompletionQuarter(let quarter, let todos): state.completionQuarterCache[quarter.quarterStart] = quarter + state.quarterTodosCache[quarter.quarterStart] = todos + case .selectDay(let day): + if let day, state.selectedDay?.date == day.date { + state.selectedDay = nil + } else { + state.selectedDay = day + } case .moveQuarter(let delta): guard let selectedQuarterStart = state.selectedQuarterStart else { break } let calendar = Calendar.current @@ -174,6 +175,7 @@ final class ProfileViewModel: Store { guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } state.selectedQuarterStart = nextQuarterStart + state.selectedDay = nil if state.completionQuarterCache[nextQuarterStart] == nil { effects = [.fetchCompletionQuarter(nextQuarterStart)] } @@ -199,6 +201,7 @@ final class ProfileViewModel: Store { self.state = state return effects } + // swiftlint:enable cyclomatic_complexity func run(_ effect: SideEffect) { switch effect { @@ -216,12 +219,12 @@ final class ProfileViewModel: Store { do { let todos = try await fetchQuarterTodos(from: quarterStart) let months = makeCompletionMonths(from: todos, quarterStart: quarterStart) - let quarter = CompletionQuarter(quarterStart: quarterStart, months: months) - send(.setCompletionQuarter(quarter)) + let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) + send(.setCompletionQuarter(quarter, todos)) } catch { let months = makeCompletionMonths(from: [], quarterStart: quarterStart) - let quarter = CompletionQuarter(quarterStart: quarterStart, months: months) - send(.setCompletionQuarter(quarter)) + let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) + send(.setCompletionQuarter(quarter, [])) } } case .updateStatusMessage(let message): @@ -233,7 +236,7 @@ final class ProfileViewModel: Store { } } case .updateHeatmapActivityTypes(let activityTypes): - let rawValues = ActivityType.allCases + let rawValues = ProfileActivityType.allCases .filter { activityTypes.contains($0) } .map(\.rawValue) updateHeatmapActivityTypesUseCase.execute(rawValues) @@ -262,11 +265,11 @@ private extension ProfileViewModel { return interval.contains(today) || quarterEnd <= today } - func normalizeActivityTypes(_ rawValues: [String]) -> Set { - Set(rawValues.compactMap(ActivityType.init(rawValue:))) + func normalizeActivityTypes(_ rawValues: [String]) -> Set { + Set(rawValues.compactMap(ProfileActivityType.init(rawValue:))) } - func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [CompletionMonth] { + func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [ProfileCompletionMonth] { let calendar = Calendar.current var dailyCreatedCount: [Date: Int] = [:] var dailyCompletedCount: [Date: Int] = [:] @@ -300,15 +303,15 @@ private extension ProfileViewModel { createdCounts: [Date: Int], completedCounts: [Date: Int], calendar: Calendar - ) -> CompletionMonth { + ) -> ProfileCompletionMonth { guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { - return CompletionMonth(monthStart: monthStart, weeks: []) + return ProfileCompletionMonth(monthStart: monthStart, weeks: []) } - var days: [CompletionDay] = [] + var days: [ProfileCompletionDay] = [] var cursor = firstWeekInterval.start while cursor < lastWeekInterval.end { let normalizedDate = calendar.startOfDay(for: cursor) @@ -316,7 +319,7 @@ private extension ProfileViewModel { let createdCount = isInMonth ? (createdCounts[normalizedDate] ?? 0) : 0 let completedCount = isInMonth ? (completedCounts[normalizedDate] ?? 0) : 0 days.append( - CompletionDay( + ProfileCompletionDay( date: normalizedDate, createdCount: createdCount, completedCount: completedCount, @@ -327,7 +330,7 @@ private extension ProfileViewModel { cursor = nextDay } - var weeks: [[CompletionDay]] = [] + var weeks: [[ProfileCompletionDay]] = [] var index = 0 while index < days.count { let endIndex = min(index + 7, days.count) @@ -335,7 +338,7 @@ private extension ProfileViewModel { index += 7 } - return CompletionMonth(monthStart: monthStart, weeks: weeks) + return ProfileCompletionMonth(monthStart: monthStart, weeks: weeks) } func startOfMonth(for date: Date, calendar: Calendar) -> Date { diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index d8dcc37..7e89b91 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -442,6 +442,9 @@ }, "확인" : { + }, + "활동 없음" : { + }, "회원 탈퇴" : { diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift new file mode 100644 index 0000000..17aacb4 --- /dev/null +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -0,0 +1,162 @@ +// +// ProfileHeatmapView.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +import SwiftUI + +struct ProfileHeatmapView: View { + let quarter: ProfileCompletionQuarter + let selectedActivityTypes: Set + let selectedDay: ProfileCompletionDay? + 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, + selectedActivityTypes: selectedActivityTypes, + selectedDay: selectedDay, + onSelectDay: onSelectDay + ) + if index < months.count - 1 { + Spacer() + } + } + } + } + + @ViewBuilder + private var weekdayLabel: some View { + let labels: [Int: String] = [ + 2: "월", + 4: "수", + 6: "금" + ] + let orderedWeekdays = Array(1...7) + let cellSize: CGFloat = 16 + + VStack(alignment: .leading, spacing: 4) { + ForEach(orderedWeekdays, id: \.self) { weekday in + Group { + if let label = labels[weekday] { + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: cellSize, height: cellSize) + } else { + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + } + .padding(.top, 22) + } +} + +private struct MonthCompactHeatmapView: View { + @Environment(\.colorScheme) private var colorScheme + let month: ProfileCompletionMonth + let selectedActivityTypes: Set + 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) { + Text(month.monthStart.formatted(.dateTime.month(.abbreviated))) + .frame(height: cellSize) + .font(.caption) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: cellSpacing) { + ForEach(orderedWeekdays, id: \.self) { weekday in + HStack(spacing: 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) + .fill(fillColor(for: day)) + .overlay( + RoundedRectangle(cornerRadius: 3) + .stroke(selectionInnerBorderColor(for: day), lineWidth: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(selectionOuterBorderColor(for: day), lineWidth: 0.8) + .padding(-1) + ) + .frame(width: cellSize, height: cellSize) + .onTapGesture { + if let day, day.isInMonth { + onSelectDay(day) + } + } + } + } + } + } + } + } + + private func isSelected(_ day: ProfileCompletionDay?) -> Bool { + guard let day, let selectedDay else { return false } + return Calendar.current.isDate(day.date, inSameDayAs: selectedDay.date) + } + + private func selectionInnerBorderColor(for day: ProfileCompletionDay?) -> Color { + isSelected(day) ? .white : .clear + } + + private func selectionOuterBorderColor(for day: ProfileCompletionDay?) -> Color { + if isSelected(day) && colorScheme == .light { + return Color.gray + } + return .clear + } + + private func fillColor(for day: ProfileCompletionDay?) -> Color { + guard let day, day.isInMonth else { return .clear } + let count = dayCount(for: day) + if count == 0 { + return Color(UIColor.systemGray5) + } + return Color.blue.opacity(opacity(for: count, max: monthMaxCount)) + } + + private var monthMaxCount: Int { + month.weeks + .flatMap { $0 } + .filter { $0.isInMonth } + .map(dayCount(for:)) + .max() ?? 0 + } + + private func dayCount(for day: ProfileCompletionDay) -> Int { + var value = 0 + if selectedActivityTypes.contains(.created) { + value += day.createdCount + } + if selectedActivityTypes.contains(.completed) { + value += day.completedCount + } + return value + } + + private func opacity(for count: Int, max: Int) -> Double { + guard 0 < count && 0 < max else { return 0 } + let ratio = Double(count) / Double(max) + return floor(ratio * 10) / 10 + } +} diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index d92960b..0129615 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -132,11 +132,22 @@ struct ProfileView: View { ProgressView() .frame(maxWidth: .infinity, minHeight: 140) } else if let quarter = viewModel.state.selectedQuarter { - QuarterHeatmapView( + ProfileHeatmapView( quarter: quarter, - selectedActivityTypes: viewModel.state.selectedActivityTypes + selectedActivityTypes: viewModel.state.selectedActivityTypes, + selectedDay: viewModel.state.selectedDay, + onSelectDay: { day in + withAnimation(.easeInOut(duration: 0.2)) { + viewModel.send(.selectDay(day)) + } + } ) .padding(.vertical, 6) + + if let selectedDay = viewModel.state.selectedDay { + selectedDayDetailSection(for: selectedDay) + .transition(.opacity.combined(with: .move(edge: .top))) + } } else { EmptyView() } @@ -150,7 +161,7 @@ struct ProfileView: View { private var activityTypeSelector: some View { Menu { - ForEach(ProfileViewModel.ActivityType.allCases, id: \.self) { activityType in + ForEach(ProfileActivityType.allCases, id: \.self) { activityType in Button { viewModel.send(.toggleActivityType(activityType)) } label: { @@ -204,124 +215,49 @@ struct ProfileView: View { return "\(year) Q\(quarter)" } - private enum Path: Hashable { - case settings - } -} - -private struct QuarterHeatmapView: View { - let quarter: ProfileViewModel.CompletionQuarter - let selectedActivityTypes: Set - - 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, - selectedActivityTypes: selectedActivityTypes - ) - if index < months.count - 1 { - Spacer() - } - } - } - } - @ViewBuilder - private var weekdayLabel: some View { - let labels: [Int: String] = [ - 2: "월", - 4: "수", - 6: "금" - ] - let orderedWeekdays = Array(1...7) - let cellSize: CGFloat = 16 + private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { + let activities = viewModel.state.selectedDayActivities - VStack(alignment: .leading, spacing: 4) { - ForEach(orderedWeekdays, id: \.self) { weekday in - Group { - if let label = labels[weekday] { - Text(label) + VStack(alignment: .leading, spacing: 8) { + Text(day.date.formatted(.dateTime.month(.wide).day())) + .font(.subheadline) + .bold() + + if activities.isEmpty { + Text("활동 없음") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 8) + } else { + ForEach(activities) { activity in + HStack(spacing: 8) { + Image(systemName: activity.todo.kind.symbolName) + .foregroundStyle(activity.todo.kind.color) + .frame(width: 20) + Text(activity.todo.title) + .font(.caption) + .lineLimit(1) + Text(activity.activityLabel) .font(.caption2) .foregroundStyle(.secondary) - .frame(width: cellSize, height: cellSize) - } else { - Color.clear - .frame(width: cellSize, height: cellSize) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color(UIColor.systemGray5)) + ) + Spacer() } + .padding(.vertical, 2) } } } - .padding(.top, 22) + .padding(.top, 4) } -} -private struct MonthCompactHeatmapView: View { - let month: ProfileViewModel.CompletionMonth - let selectedActivityTypes: Set - 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) { - Text(month.monthStart.formatted(.dateTime.month(.abbreviated))) - .frame(height: cellSize) - .font(.caption) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: cellSpacing) { - ForEach(orderedWeekdays, id: \.self) { weekday in - HStack(spacing: 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) - .fill(fillColor(for: day)) - .frame(width: cellSize, height: cellSize) - } - } - } - } - } - } - - private func fillColor(for day: ProfileViewModel.CompletionDay?) -> Color { - guard let day, day.isInMonth else { return .clear } - let count = dayCount(for: day) - if count == 0 { - return Color(UIColor.systemGray5) - } - return Color.blue.opacity(opacity(for: count, max: monthMaxCount)) - } - - private var monthMaxCount: Int { - month.weeks - .flatMap { $0 } - .filter { $0.isInMonth } - .map(dayCount(for:)) - .max() ?? 0 - } - - private func dayCount(for day: ProfileViewModel.CompletionDay) -> Int { - var value = 0 - if selectedActivityTypes.contains(.created) { - value += day.createdCount - } - if selectedActivityTypes.contains(.completed) { - value += day.completedCount - } - return value - } - - private func opacity(for count: Int, max: Int) -> Double { - guard 0 < count && 0 < max else { return 0 } - let ratio = Double(count) / Double(max) - return floor(ratio * 10) / 10 + private enum Path: Hashable { + case settings } } From 03e95fdcaa76dcb5a03f56e79254624140ffdf9b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 03:50:06 +0900 Subject: [PATCH 08/28] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EC=9D=84=20=ED=83=AD=ED=95=98=EB=A9=B4=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=ED=99=9C=EB=8F=99=EC=9D=98=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=EC=9D=84=20=EB=B3=BC=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=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 | 5 + DevLog/UI/Profile/ProfileView.swift | 155 ++++++++++++++++-- 2 files changed, 143 insertions(+), 17 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 7780175..ca62ce4 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -18,6 +18,7 @@ final class ProfileViewModel: Store { var quarterTodosCache: [Date: [Todo]] = [:] var selectedActivityTypes: Set = [.created, .completed] var selectedDay: ProfileCompletionDay? + var selectedActivityForSheet: ProfileSelectedDayActivity? var showDoneButton: Bool = false var showAlert: Bool = false var alertTitle: String = "" @@ -92,6 +93,7 @@ final class ProfileViewModel: Store { case moveQuarter(Int) case toggleActivityType(ProfileActivityType) case selectDay(ProfileCompletionDay?) + case setSelectedActivityForSheet(ProfileSelectedDayActivity?) case updateStatusMessage(String) case updateStatusTextFieldFocus(Bool) } @@ -162,6 +164,8 @@ final class ProfileViewModel: Store { } else { state.selectedDay = day } + case .setSelectedActivityForSheet(let activity): + state.selectedActivityForSheet = activity case .moveQuarter(let delta): guard let selectedQuarterStart = state.selectedQuarterStart else { break } let calendar = Calendar.current @@ -176,6 +180,7 @@ final class ProfileViewModel: Store { state.selectedQuarterStart = nextQuarterStart state.selectedDay = nil + state.selectedActivityForSheet = nil if state.completionQuarterCache[nextQuarterStart] == nil { effects = [.fetchCompletionQuarter(nextQuarterStart)] } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 0129615..c46914f 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import MarkdownUI struct ProfileView: View { @StateObject var viewModel: ProfileViewModel @@ -114,6 +115,12 @@ struct ProfileView: View { } message: { Text(viewModel.state.alertMessage) } + .sheet(item: Binding( + get: { viewModel.state.selectedActivityForSheet }, + set: { viewModel.send(.setSelectedActivityForSheet($0)) } + )) { activity in + ProfileActivityTodoSheetView(activity: activity) + } } } @@ -232,24 +239,29 @@ struct ProfileView: View { .padding(.vertical, 8) } else { ForEach(activities) { activity in - HStack(spacing: 8) { - Image(systemName: activity.todo.kind.symbolName) - .foregroundStyle(activity.todo.kind.color) - .frame(width: 20) - Text(activity.todo.title) - .font(.caption) - .lineLimit(1) - Text(activity.activityLabel) - .font(.caption2) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color(UIColor.systemGray5)) - ) - Spacer() + Button { + viewModel.send(.setSelectedActivityForSheet(activity)) + } label: { + HStack(spacing: 8) { + Image(systemName: activity.todo.kind.symbolName) + .foregroundStyle(activity.todo.kind.color) + .frame(width: 20) + Text(activity.todo.title) + .font(.caption) + .lineLimit(1) + Text(activity.activityLabel) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule() + .fill(Color(.systemGray4)) + ) + Spacer() + } } + .buttonStyle(.plain) .padding(.vertical, 2) } } @@ -261,3 +273,112 @@ struct ProfileView: View { case settings } } + +private struct ProfileActivityTodoSheetView: View { + @Environment(\.dismiss) private var dismiss + let activity: ProfileSelectedDayActivity + @State private var showInfo: Bool = false + + var body: some View { + NavigationStack { + ZStack { + Color(.secondarySystemBackground).ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + HStack { + Text(activity.activityLabel) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color(.systemGray4)) + ) + Spacer() + } + .padding(.horizontal) + Text(activity.todo.title) + .font(.title3.bold()) + .padding(.horizontal) + Divider() + Markdown(activity.todo.content) + .padding(.horizontal) + } + } + } + .sheet(isPresented: $showInfo) { + infoSheetContent + } + .toolbar { + ToolbarLeadingButton { + dismiss() + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + } + } + } + } + } + + private var infoSheetContent: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 32) { + VStack { + HStack { + Text("마감일") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + HStack(spacing: 8) { + Image(systemName: "calendar") + .foregroundStyle(.secondary) + Text( + activity.todo.dueDate? + .formatted(date: .abbreviated, time: .omitted) + ?? "마감일 없음" + ) + .foregroundStyle(activity.todo.dueDate == nil ? .secondary : .primary) + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.tertiarySystemFill)) + ) + Divider() + } + VStack { + HStack { + Text("태그") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + Divider() + if !activity.todo.tags.isEmpty { + TagLayout { + ForEach(activity.todo.tags, id: \.self) { tag in + Tag(tag, isEditing: false) + } + } + } + } + } + .padding(.horizontal) + } + .toolbar { + ToolbarLeadingButton { + showInfo = false + } + } + } + } +} From d442935d65928ea0f3589f12bc734f343d425308 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 04:00:27 +0900 Subject: [PATCH 09/28] =?UTF-8?q?refactor:=20=EB=B9=84=EC=8A=B7=ED=95=9C?= =?UTF-8?q?=20UI=EC=9A=94=EC=86=8C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/TodoDetailContentView.swift | 43 ++++++++++ DevLog/UI/Common/TodoInfoSheetView.swift | 71 ++++++++++++++++ DevLog/UI/Home/TodoDetailView.swift | 75 ++--------------- DevLog/UI/Profile/ProfileView.swift | 87 +++----------------- 4 files changed, 133 insertions(+), 143 deletions(-) create mode 100644 DevLog/UI/Common/TodoDetailContentView.swift create mode 100644 DevLog/UI/Common/TodoInfoSheetView.swift diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift new file mode 100644 index 0000000..fb27a1b --- /dev/null +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -0,0 +1,43 @@ +// +// TodoDetailContentView.swift +// DevLog +// +// Created by opfic on 3/2/26. +// + +import SwiftUI +import MarkdownUI + +struct TodoDetailContentView: View { + let title: String + let content: String + var activityLabel: String? = nil + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if let activityLabel { + HStack { + Text(activityLabel) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color(.systemGray4)) + ) + Spacer() + } + .padding(.horizontal) + } + Text(title) + .font(.title3.bold()) + .padding(.horizontal) + Divider() + Markdown(content) + .padding(.horizontal) + } + } + } +} diff --git a/DevLog/UI/Common/TodoInfoSheetView.swift b/DevLog/UI/Common/TodoInfoSheetView.swift new file mode 100644 index 0000000..1cecba8 --- /dev/null +++ b/DevLog/UI/Common/TodoInfoSheetView.swift @@ -0,0 +1,71 @@ +// +// TodoInfoSheetView.swift +// DevLog +// +// Created by 최윤진 on 3/2/26. +// + +import SwiftUI + +struct TodoInfoSheetView: View { + let dueDate: Date? + let tags: [String] + let onClose: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 32) { + VStack { + HStack { + Text("마감일") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + HStack(spacing: 8) { + Image(systemName: "calendar") + .foregroundStyle(.secondary) + Text( + dueDate? + .formatted(date: .abbreviated, time: .omitted) + ?? "마감일 없음" + ) + .foregroundStyle(dueDate == nil ? .secondary : .primary) + Spacer() + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.tertiarySystemFill)) + ) + Divider() + } + VStack { + HStack { + Text("태그") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + Divider() + if !tags.isEmpty { + TagLayout { + ForEach(tags, id: \.self) { tag in + Tag(tag, isEditing: false) + } + } + } + } + } + .padding(.horizontal) + } + .toolbar { + ToolbarLeadingButton { + onClose() + } + } + } + } +} diff --git a/DevLog/UI/Home/TodoDetailView.swift b/DevLog/UI/Home/TodoDetailView.swift index 52faa11..12e5af9 100644 --- a/DevLog/UI/Home/TodoDetailView.swift +++ b/DevLog/UI/Home/TodoDetailView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import MarkdownUI struct TodoDetailView: View { @StateObject var viewModel: TodoDetailViewModel @@ -15,16 +14,10 @@ struct TodoDetailView: View { ZStack { Color(.secondarySystemBackground).ignoresSafeArea() if let todo = viewModel.state.todo { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - Text(todo.title) - .font(.title3.bold()) - .padding(.horizontal) - Divider() - Markdown(todo.content) - .padding(.horizontal) - } - } + TodoDetailContentView( + title: todo.title, + content: todo.content + ) } else if viewModel.state.isLoading { LoadingView() } @@ -72,61 +65,11 @@ struct TodoDetailView: View { } private var sheetContent: some View { - NavigationStack { - ScrollView { - LazyVStack(spacing: 32) { - VStack { - HStack { - Text("마감일") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - HStack(spacing: 8) { - Image(systemName: "calendar") - .foregroundStyle(.secondary) - Text( - viewModel.state.todo?.dueDate? - .formatted(date: .abbreviated, time: .omitted) - ?? "마감일 없음" - ) - .foregroundStyle( - viewModel.state.todo?.dueDate == nil ? .secondary : .primary - ) - Spacer() - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.tertiarySystemFill)) - ) - Divider() - } - VStack { - HStack { - Text("태그") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - Divider() - if let tags = viewModel.state.todo?.tags, !tags.isEmpty { - TagLayout { - ForEach(tags, id: \.self) { tag in - Tag(tag, isEditing: false) - } - } - } - } - } - .padding(.horizontal) - } - .toolbar { - ToolbarLeadingButton { - viewModel.send(.setShowInfo(false)) - } - } + TodoInfoSheetView( + dueDate: viewModel.state.todo?.dueDate, + tags: viewModel.state.todo?.tags ?? [] + ) { + viewModel.send(.setShowInfo(false)) } } } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index c46914f..66cd711 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import MarkdownUI struct ProfileView: View { @StateObject var viewModel: ProfileViewModel @@ -283,29 +282,11 @@ private struct ProfileActivityTodoSheetView: View { NavigationStack { ZStack { Color(.secondarySystemBackground).ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - HStack { - Text(activity.activityLabel) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(Color(.systemGray4)) - ) - Spacer() - } - .padding(.horizontal) - Text(activity.todo.title) - .font(.title3.bold()) - .padding(.horizontal) - Divider() - Markdown(activity.todo.content) - .padding(.horizontal) - } - } + TodoDetailContentView( + title: activity.todo.title, + content: activity.todo.content, + activityLabel: activity.activityLabel + ) } .sheet(isPresented: $showInfo) { infoSheetContent @@ -326,59 +307,11 @@ private struct ProfileActivityTodoSheetView: View { } private var infoSheetContent: some View { - NavigationStack { - ScrollView { - LazyVStack(spacing: 32) { - VStack { - HStack { - Text("마감일") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - HStack(spacing: 8) { - Image(systemName: "calendar") - .foregroundStyle(.secondary) - Text( - activity.todo.dueDate? - .formatted(date: .abbreviated, time: .omitted) - ?? "마감일 없음" - ) - .foregroundStyle(activity.todo.dueDate == nil ? .secondary : .primary) - Spacer() - } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.tertiarySystemFill)) - ) - Divider() - } - VStack { - HStack { - Text("태그") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } - Divider() - if !activity.todo.tags.isEmpty { - TagLayout { - ForEach(activity.todo.tags, id: \.self) { tag in - Tag(tag, isEditing: false) - } - } - } - } - } - .padding(.horizontal) - } - .toolbar { - ToolbarLeadingButton { - showInfo = false - } - } + TodoInfoSheetView( + dueDate: activity.todo.dueDate, + tags: activity.todo.tags + ) { + showInfo = false } } } From 30718ba74f6e58e80d441509447445d390e46fb3 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 04:01:06 +0900 Subject: [PATCH 10/28] =?UTF-8?q?chore:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=AA=85=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/{Componeent => Component}/CacheableImage.swift | 0 DevLog/UI/Common/{Componeent => Component}/CheckBox.swift | 0 DevLog/UI/Common/{Componeent => Component}/LoadingView.swift | 0 DevLog/UI/Common/{Componeent => Component}/LoginButton.swift | 0 DevLog/UI/Common/{Componeent => Component}/Tag+.swift | 0 DevLog/UI/Common/{Componeent => Component}/Toast.swift | 0 DevLog/UI/Common/{Componeent => Component}/TodoItemRow.swift | 0 DevLog/UI/Common/{Componeent => Component}/ToolbarButton+.swift | 0 DevLog/UI/Common/{Componeent => Component}/WebItemRow.swift | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename DevLog/UI/Common/{Componeent => Component}/CacheableImage.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/CheckBox.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/LoadingView.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/LoginButton.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/Tag+.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/Toast.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/TodoItemRow.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/ToolbarButton+.swift (100%) rename DevLog/UI/Common/{Componeent => Component}/WebItemRow.swift (100%) diff --git a/DevLog/UI/Common/Componeent/CacheableImage.swift b/DevLog/UI/Common/Component/CacheableImage.swift similarity index 100% rename from DevLog/UI/Common/Componeent/CacheableImage.swift rename to DevLog/UI/Common/Component/CacheableImage.swift diff --git a/DevLog/UI/Common/Componeent/CheckBox.swift b/DevLog/UI/Common/Component/CheckBox.swift similarity index 100% rename from DevLog/UI/Common/Componeent/CheckBox.swift rename to DevLog/UI/Common/Component/CheckBox.swift diff --git a/DevLog/UI/Common/Componeent/LoadingView.swift b/DevLog/UI/Common/Component/LoadingView.swift similarity index 100% rename from DevLog/UI/Common/Componeent/LoadingView.swift rename to DevLog/UI/Common/Component/LoadingView.swift diff --git a/DevLog/UI/Common/Componeent/LoginButton.swift b/DevLog/UI/Common/Component/LoginButton.swift similarity index 100% rename from DevLog/UI/Common/Componeent/LoginButton.swift rename to DevLog/UI/Common/Component/LoginButton.swift diff --git a/DevLog/UI/Common/Componeent/Tag+.swift b/DevLog/UI/Common/Component/Tag+.swift similarity index 100% rename from DevLog/UI/Common/Componeent/Tag+.swift rename to DevLog/UI/Common/Component/Tag+.swift diff --git a/DevLog/UI/Common/Componeent/Toast.swift b/DevLog/UI/Common/Component/Toast.swift similarity index 100% rename from DevLog/UI/Common/Componeent/Toast.swift rename to DevLog/UI/Common/Component/Toast.swift diff --git a/DevLog/UI/Common/Componeent/TodoItemRow.swift b/DevLog/UI/Common/Component/TodoItemRow.swift similarity index 100% rename from DevLog/UI/Common/Componeent/TodoItemRow.swift rename to DevLog/UI/Common/Component/TodoItemRow.swift diff --git a/DevLog/UI/Common/Componeent/ToolbarButton+.swift b/DevLog/UI/Common/Component/ToolbarButton+.swift similarity index 100% rename from DevLog/UI/Common/Componeent/ToolbarButton+.swift rename to DevLog/UI/Common/Component/ToolbarButton+.swift diff --git a/DevLog/UI/Common/Componeent/WebItemRow.swift b/DevLog/UI/Common/Component/WebItemRow.swift similarity index 100% rename from DevLog/UI/Common/Componeent/WebItemRow.swift rename to DevLog/UI/Common/Component/WebItemRow.swift From 968b077adbd7be58e5b223006eefad3cffa5ae0b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 04:10:46 +0900 Subject: [PATCH 11/28] =?UTF-8?q?ui:=20=EB=85=84=EB=8F=84=EA=B0=80=20?= =?UTF-8?q?=EC=83=81=EC=8B=9C=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/TodoDetailContentView.swift | 2 +- DevLog/UI/Profile/ProfileView.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift index fb27a1b..816886b 100644 --- a/DevLog/UI/Common/TodoDetailContentView.swift +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -11,7 +11,7 @@ import MarkdownUI struct TodoDetailContentView: View { let title: String let content: String - var activityLabel: String? = nil + var activityLabel: String? var body: some View { ScrollView { diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 66cd711..33b4675 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -75,9 +75,9 @@ struct ProfileView: View { .padding(.horizontal, 16) } .frame(maxWidth: .infinity) - .background(Color(UIColor.systemGroupedBackground)) + .background(Color(.systemGroupedBackground)) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 0) { Button { router.push(Path.settings) @@ -161,7 +161,7 @@ struct ProfileView: View { .padding(12) .background( RoundedRectangle(cornerRadius: 14) - .fill(Color(UIColor.secondarySystemGroupedBackground)) + .fill(Color(.secondarySystemGroupedBackground)) ) } @@ -226,7 +226,7 @@ struct ProfileView: View { let activities = viewModel.state.selectedDayActivities VStack(alignment: .leading, spacing: 8) { - Text(day.date.formatted(.dateTime.month(.wide).day())) + Text(day.date.formatted(.dateTime.year().month(.wide).day())) .font(.subheadline) .bold() From 57fd641a774c77d3a8fe35d9f80f778e1927e4f2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 04:12:01 +0900 Subject: [PATCH 12/28] =?UTF-8?q?refactor:=20=ED=84=B0=EC=B9=98=EC=84=B1?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 33b4675..d10f0e4 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -259,6 +259,7 @@ struct ProfileView: View { ) Spacer() } + .contentShape(.rect) } .buttonStyle(.plain) .padding(.vertical, 2) From da05b51562cc64a1ee03a121d0b80ceef36246dd Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 04:23:51 +0900 Subject: [PATCH 13/28] =?UTF-8?q?ui:=20=ED=88=AC=EB=AA=85=EB=8F=84=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=EB=A7=8C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= 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 d10f0e4..9aed280 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -152,7 +152,7 @@ struct ProfileView: View { if let selectedDay = viewModel.state.selectedDay { selectedDayDetailSection(for: selectedDay) - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity) } } else { EmptyView() From 60ecf3ff781f840028f4d5416d8f2c6902a0bd9a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 10:46:33 +0900 Subject: [PATCH 14/28] =?UTF-8?q?ui:=20=EC=83=81=EC=84=B8=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=EC=9D=84=20=EB=82=B4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=ED=95=B4=EC=84=9C=20=EB=B3=B4=EC=97=AC=EC=A3=BC?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=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 | 79 ++++++++++++++--------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 9aed280..9476875 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -87,15 +87,20 @@ struct ProfileView: View { } } } - .navigationDestination(for: Path.self) { _ in - SettingView(viewModel: SettingViewModel( - deleteAuthUseCase: container.resolve(DeleteAuthUseCase.self), - signOutUseCase: container.resolve(SignOutUseCase.self), - sessionUseCase: container.resolve(AuthSessionUseCase.self), - observeSystemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), - updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) - )) - .environmentObject(router) + .navigationDestination(for: Path.self) { path in + switch path { + case .settings: + SettingView(viewModel: SettingViewModel( + deleteAuthUseCase: container.resolve(DeleteAuthUseCase.self), + signOutUseCase: container.resolve(SignOutUseCase.self), + sessionUseCase: container.resolve(AuthSessionUseCase.self), + observeSystemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self), + updateSystemThemeUseCase: container.resolve(UpdateSystemThemeUseCase.self) + )) + .environmentObject(router) + case .activity(let activity): + ProfileActivityTodoDetailView(activity: activity) + } } .onAppear { viewModel.send(.onAppear) @@ -114,12 +119,6 @@ struct ProfileView: View { } message: { Text(viewModel.state.alertMessage) } - .sheet(item: Binding( - get: { viewModel.state.selectedActivityForSheet }, - set: { viewModel.send(.setSelectedActivityForSheet($0)) } - )) { activity in - ProfileActivityTodoSheetView(activity: activity) - } } } @@ -225,7 +224,7 @@ struct ProfileView: View { private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { let activities = viewModel.state.selectedDayActivities - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { Text(day.date.formatted(.dateTime.year().month(.wide).day())) .font(.subheadline) .bold() @@ -239,7 +238,7 @@ struct ProfileView: View { } else { ForEach(activities) { activity in Button { - viewModel.send(.setSelectedActivityForSheet(activity)) + router.push(Path.activity(activity)) } label: { HStack(spacing: 8) { Image(systemName: activity.todo.kind.symbolName) @@ -258,6 +257,9 @@ struct ProfileView: View { .fill(Color(.systemGray4)) ) Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) } .contentShape(.rect) } @@ -271,37 +273,32 @@ struct ProfileView: View { private enum Path: Hashable { case settings + case activity(ProfileSelectedDayActivity) } } -private struct ProfileActivityTodoSheetView: View { - @Environment(\.dismiss) private var dismiss +private struct ProfileActivityTodoDetailView: View { let activity: ProfileSelectedDayActivity @State private var showInfo: Bool = false var body: some View { - NavigationStack { - ZStack { - Color(.secondarySystemBackground).ignoresSafeArea() - TodoDetailContentView( - title: activity.todo.title, - content: activity.todo.content, - activityLabel: activity.activityLabel - ) - } - .sheet(isPresented: $showInfo) { - infoSheetContent - } - .toolbar { - ToolbarLeadingButton { - dismiss() - } - ToolbarItem(placement: .topBarTrailing) { - Button { - showInfo = true - } label: { - Image(systemName: "info.circle") - } + ZStack { + Color(.secondarySystemBackground).ignoresSafeArea() + TodoDetailContentView( + title: activity.todo.title, + content: activity.todo.content, + activityLabel: activity.activityLabel + ) + } + .sheet(isPresented: $showInfo) { + infoSheetContent + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") } } } From b24d4c08a5a2cbabb35b00149d410b56e94873e6 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 10:53:58 +0900 Subject: [PATCH 15/28] =?UTF-8?q?ui:=20TodoDetailContentView=EA=B0=80=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD=EC=83=89=EC=9D=84=20=EA=B0=80=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/TodoDetailContentView.swift | 45 +++++++++++--------- DevLog/UI/Profile/ProfileView.swift | 13 +++--- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/DevLog/UI/Common/TodoDetailContentView.swift b/DevLog/UI/Common/TodoDetailContentView.swift index 816886b..04ac5a4 100644 --- a/DevLog/UI/Common/TodoDetailContentView.swift +++ b/DevLog/UI/Common/TodoDetailContentView.swift @@ -14,29 +14,32 @@ struct TodoDetailContentView: View { var activityLabel: String? var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - if let activityLabel { - HStack { - Text(activityLabel) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(Color(.systemGray4)) - ) - Spacer() + ZStack { + Color(.secondarySystemBackground).ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if let activityLabel { + HStack { + Text(activityLabel) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color(.systemGray4)) + ) + Spacer() + } + .padding(.horizontal) } - .padding(.horizontal) + Text(title) + .font(.title3.bold()) + .padding(.horizontal) + Divider() + Markdown(content) + .padding(.horizontal) } - Text(title) - .font(.title3.bold()) - .padding(.horizontal) - Divider() - Markdown(content) - .padding(.horizontal) } } } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 9476875..0e507ce 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -282,14 +282,11 @@ private struct ProfileActivityTodoDetailView: View { @State private var showInfo: Bool = false var body: some View { - ZStack { - Color(.secondarySystemBackground).ignoresSafeArea() - TodoDetailContentView( - title: activity.todo.title, - content: activity.todo.content, - activityLabel: activity.activityLabel - ) - } + TodoDetailContentView( + title: activity.todo.title, + content: activity.todo.content, + activityLabel: activity.activityLabel + ) .sheet(isPresented: $showInfo) { infoSheetContent } From b783d0b5deb55686ba72c6409b916a8de9b73eb2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 11:28:05 +0900 Subject: [PATCH 16/28] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8=EC=9D=84=20=EB=A1=9C=EA=B7=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=98=EA=B3=A0,=20nil=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=8B=8C=20=EC=BF=BC=EB=A6=AC=EB=A7=8C=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/TodoService.swift | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 6573897..065917e 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -20,15 +20,17 @@ final class TodoService { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } let trimmedKeyword = query.keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let logMessage = "Fetching todo page: kind=\(String(describing: query.kind)), " - + "keyword=\(trimmedKeyword), " - + "pinned=\(String(describing: query.isPinned)), " - + "createdAtFrom=\(String(describing: query.createdAtFrom)), " - + "createdAtTo=\(String(describing: query.createdAtTo)), " - + "createdAtDescending=\(query.createdAtDescending), " - + "pageSize=\(String(describing: query.pageSize)), " - + "cursor=\(String(describing: cursor))" - logger.info(logMessage) + let logComponents: [String?] = [ + "createdAtDescending=\(query.createdAtDescending)", + query.keyword != nil ? "keywordLength=\(trimmedKeyword.count)" : nil, + query.kind != nil ? "kind=\(query.kind!.rawValue)" : nil, + query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, + query.createdAtFrom != nil ? "createdAtFrom=\(query.createdAtFrom!)" : nil, + query.createdAtTo != nil ? "createdAtTo=\(query.createdAtTo!)" : nil, + query.pageSize != nil ? "pageSize=\(query.pageSize!)" : nil, + cursor != nil ? "cursor=\(cursor!)" : nil + ] + logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") var firestoreQuery: Query = store .collection("users/\(uid)/todoLists/") From d3e575bead3d81a87754640cf90a33c412f83832 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 11:41:53 +0900 Subject: [PATCH 17/28] =?UTF-8?q?refactor:=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=93=A4=EC=9D=80=20State=20=EB=B0=96?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 109 ++++++++---------- DevLog/UI/Profile/ProfileView.swift | 12 +- 2 files changed, 57 insertions(+), 64 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index ca62ce4..3837c3d 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -23,64 +23,6 @@ final class ProfileViewModel: Store { var showAlert: Bool = false var alertTitle: String = "" var alertMessage: String = "" - var resetButtonEnabled: Bool { - !statusMessage.isEmpty && showDoneButton - } - - var selectedQuarter: ProfileCompletionQuarter? { - guard let selectedQuarterStart else { return nil } - return completionQuarterCache[selectedQuarterStart] - } - - var selectedDayActivities: [ProfileSelectedDayActivity] { - guard let selectedDay, - let selectedQuarterStart, - let todos = quarterTodosCache[selectedQuarterStart] else { return [] } - let calendar = Calendar.current - let dayStart = calendar.startOfDay(for: selectedDay.date) - - return todos.compactMap { todo in - let isCreated = selectedActivityTypes.contains(.created) - && calendar.startOfDay(for: todo.createdAt) == dayStart - let isCompleted = selectedActivityTypes.contains(.completed) - && todo.isCompleted - && calendar.startOfDay(for: todo.updatedAt) == dayStart - guard isCreated || isCompleted else { return nil } - return ProfileSelectedDayActivity( - todo: todo, - showsCreated: isCreated, - showsCompleted: isCompleted - ) - } - } - - var canMoveToPreviousQuarter: Bool { - guard let selectedQuarterStart else { return false } - let calendar = Calendar.current - guard let previousQuarterStart = calendar.date(byAdding: .month, value: -3, to: selectedQuarterStart) else { - return false - } - let today = calendar.startOfDay(for: Date()) - return Self.canMove(to: previousQuarterStart, calendar: calendar, today: today) - } - - var canMoveToNextQuarter: Bool { - guard let selectedQuarterStart else { return false } - let calendar = Calendar.current - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: selectedQuarterStart) else { - return false - } - let today = calendar.startOfDay(for: Date()) - return Self.canMove(to: nextQuarterStart, calendar: calendar, today: today) - } - - private static 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 - } } enum Action { @@ -112,6 +54,57 @@ final class ProfileViewModel: Store { private let fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase + var resetButtonEnabled: Bool { + !state.statusMessage.isEmpty && state.showDoneButton + } + + var selectedQuarter: ProfileCompletionQuarter? { + guard let selectedQuarterStart = state.selectedQuarterStart else { return nil } + return state.completionQuarterCache[selectedQuarterStart] + } + + var selectedDayActivities: [ProfileSelectedDayActivity] { + guard let selectedDay = state.selectedDay, + let selectedQuarterStart = state.selectedQuarterStart, + let todos = state.quarterTodosCache[selectedQuarterStart] else { return [] } + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: selectedDay.date) + + return todos.compactMap { todo in + let isCreated = state.selectedActivityTypes.contains(.created) + && calendar.startOfDay(for: todo.createdAt) == dayStart + let isCompleted = state.selectedActivityTypes.contains(.completed) + && todo.isCompleted + && calendar.startOfDay(for: todo.updatedAt) == dayStart + guard isCreated || isCompleted else { return nil } + return ProfileSelectedDayActivity( + todo: todo, + showsCreated: isCreated, + showsCompleted: isCompleted + ) + } + } + + var canMoveToPreviousQuarter: Bool { + guard let selectedQuarterStart = state.selectedQuarterStart else { return false } + let calendar = Calendar.current + guard let previousQuarterStart = calendar.date(byAdding: .month, value: -3, to: selectedQuarterStart) else { + return false + } + let today = calendar.startOfDay(for: Date()) + return canMove(to: previousQuarterStart, calendar: calendar, today: today) + } + + var canMoveToNextQuarter: Bool { + guard let selectedQuarterStart = state.selectedQuarterStart else { return false } + let calendar = Calendar.current + guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: selectedQuarterStart) else { + return false + } + let today = calendar.startOfDay(for: Date()) + return canMove(to: nextQuarterStart, calendar: calendar, today: today) + } + init( fetchUserDataUseCase: FetchUserDataUseCase, fetchTodosByDateRangeUseCase: FetchTodosByDateRangeUseCase, diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 0e507ce..bba2d60 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -45,7 +45,7 @@ struct ProfileView: View { } .focused($focusedOnStatusMessageTextField) - if viewModel.state.resetButtonEnabled { + if viewModel.resetButtonEnabled { Button(action: { viewModel.send(.tapResetStatusMessageButton) }) { @@ -133,10 +133,10 @@ struct ProfileView: View { quarterNavigator - if viewModel.state.selectedQuarter == nil { + if viewModel.selectedQuarter == nil { ProgressView() .frame(maxWidth: .infinity, minHeight: 140) - } else if let quarter = viewModel.state.selectedQuarter { + } else if let quarter = viewModel.selectedQuarter { ProfileHeatmapView( quarter: quarter, selectedActivityTypes: viewModel.state.selectedActivityTypes, @@ -192,7 +192,7 @@ struct ProfileView: View { } label: { Image(systemName: "chevron.left") } - .disabled(!viewModel.state.canMoveToPreviousQuarter) + .disabled(!viewModel.canMoveToPreviousQuarter) Spacer() @@ -207,7 +207,7 @@ struct ProfileView: View { } label: { Image(systemName: "chevron.right") } - .disabled(!viewModel.state.canMoveToNextQuarter) + .disabled(!viewModel.canMoveToNextQuarter) } } @@ -222,7 +222,7 @@ struct ProfileView: View { @ViewBuilder private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { - let activities = viewModel.state.selectedDayActivities + let activities = viewModel.selectedDayActivities VStack(alignment: .leading, spacing: 12) { Text(day.date.formatted(.dateTime.year().month(.wide).day())) From ecd6a26b39365b81888a6b2dd5f884082533cd0e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 11:48:07 +0900 Subject: [PATCH 18/28] =?UTF-8?q?refactor:=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EC=96=BC=EB=9F=BF=EC=9D=84=20?= =?UTF-8?q?=EB=9D=84=EC=9A=B0=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/ProfileViewModel.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 3837c3d..c2c0cf0 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -220,9 +220,7 @@ final class ProfileViewModel: Store { let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) send(.setCompletionQuarter(quarter, todos)) } catch { - let months = makeCompletionMonths(from: [], quarterStart: quarterStart) - let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) - send(.setCompletionQuarter(quarter, [])) + send(.setAlert(true)) } } case .updateStatusMessage(let message): From db2121e91465ec286543eafe235c14812d0d631e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 11:55:17 +0900 Subject: [PATCH 19/28] =?UTF-8?q?refactor:=20monthMaxCount=EA=B0=80=20?= =?UTF-8?q?=EA=B0=81=20day=20=EC=85=80=EC=97=90=20=EB=8C=80=ED=95=B4=20?= =?UTF-8?q?=EB=B0=98=EB=B3=B5=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EB=90=98=EB=8A=94=20=ED=98=95=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=9C=EB=B2=88=EB=A7=8C=20=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 --- DevLog/UI/Profile/ProfileHeatmapView.swift | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index 17aacb4..52b1441 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -72,6 +72,12 @@ private struct MonthCompactHeatmapView: View { private let cellSpacing: CGFloat = 4 var body: some View { + let maxCount = month.weeks + .flatMap { $0 } + .filter { $0.isInMonth } + .map(dayCount(for:)) + .max() ?? 0 + VStack(alignment: .leading, spacing: 6) { Text(month.monthStart.formatted(.dateTime.month(.abbreviated))) .frame(height: cellSize) @@ -87,7 +93,7 @@ private struct MonthCompactHeatmapView: View { } RoundedRectangle(cornerRadius: 3) - .fill(fillColor(for: day)) + .fill(fillColor(for: day, with: maxCount)) .overlay( RoundedRectangle(cornerRadius: 3) .stroke(selectionInnerBorderColor(for: day), lineWidth: 2) @@ -126,21 +132,13 @@ private struct MonthCompactHeatmapView: View { return .clear } - private func fillColor(for day: ProfileCompletionDay?) -> Color { + private func fillColor(for day: ProfileCompletionDay?, with maxCount: Int) -> Color { guard let day, day.isInMonth else { return .clear } let count = dayCount(for: day) if count == 0 { - return Color(UIColor.systemGray5) + return Color(.systemGray5) } - return Color.blue.opacity(opacity(for: count, max: monthMaxCount)) - } - - private var monthMaxCount: Int { - month.weeks - .flatMap { $0 } - .filter { $0.isInMonth } - .map(dayCount(for:)) - .max() ?? 0 + return Color.blue.opacity(opacity(for: count, max: maxCount)) } private func dayCount(for day: ProfileCompletionDay) -> Int { From 22a27db69c56d57b12952a9e8e41fadc4d83d06b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 12:12:20 +0900 Subject: [PATCH 20/28] =?UTF-8?q?refactor:=20Calendar=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/ProfileViewModel.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index c2c0cf0..09006fd 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -53,6 +53,7 @@ final class ProfileViewModel: Store { private let upsertStatusMessageUseCase: UpsertStatusMessageUseCase private let fetchHeatmapActivityTypesUseCase: FetchProfileHeatmapActivityTypesUseCase private let updateHeatmapActivityTypesUseCase: UpdateProfileHeatmapActivityTypesUseCase + private let calendar = Calendar.current var resetButtonEnabled: Bool { !state.statusMessage.isEmpty && state.showDoneButton @@ -67,7 +68,6 @@ final class ProfileViewModel: Store { guard let selectedDay = state.selectedDay, let selectedQuarterStart = state.selectedQuarterStart, let todos = state.quarterTodosCache[selectedQuarterStart] else { return [] } - let calendar = Calendar.current let dayStart = calendar.startOfDay(for: selectedDay.date) return todos.compactMap { todo in @@ -87,7 +87,6 @@ final class ProfileViewModel: Store { var canMoveToPreviousQuarter: Bool { guard let selectedQuarterStart = state.selectedQuarterStart else { return false } - let calendar = Calendar.current guard let previousQuarterStart = calendar.date(byAdding: .month, value: -3, to: selectedQuarterStart) else { return false } @@ -97,7 +96,6 @@ final class ProfileViewModel: Store { var canMoveToNextQuarter: Bool { guard let selectedQuarterStart = state.selectedQuarterStart else { return false } - let calendar = Calendar.current guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: selectedQuarterStart) else { return false } @@ -125,7 +123,6 @@ final class ProfileViewModel: Store { var effects: [SideEffect] = [] switch action { case .onAppear: - let calendar = Calendar.current if state.selectedQuarterStart == nil { state.selectedQuarterStart = quarterStart(for: Date(), calendar: calendar) } @@ -161,7 +158,6 @@ final class ProfileViewModel: Store { state.selectedActivityForSheet = activity case .moveQuarter(let delta): guard let selectedQuarterStart = state.selectedQuarterStart else { break } - let calendar = Calendar.current let monthDelta = 3 * delta guard let nextQuarterStart = calendar.date( byAdding: .month, @@ -242,7 +238,6 @@ final class ProfileViewModel: Store { private extension ProfileViewModel { func fetchQuarterTodos(from quarterStart: Date) async throws -> [Todo] { - let calendar = Calendar.current guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { return [] } @@ -266,7 +261,6 @@ private extension ProfileViewModel { } func makeCompletionMonths(from todos: [Todo], quarterStart: Date) -> [ProfileCompletionMonth] { - let calendar = Calendar.current var dailyCreatedCount: [Date: Int] = [:] var dailyCompletedCount: [Date: Int] = [:] From 0fe1db657dc7a11b894650774b6dc02f308ddce2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 12:12:24 +0900 Subject: [PATCH 21/28] =?UTF-8?q?refactor:=20=EB=B7=B0=EC=97=90=EC=84=9C?= =?UTF-8?q?=20Presentation=20=EC=B1=85=EC=9E=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/ProfileViewModel.swift | 8 ++++++++ DevLog/UI/Profile/ProfileView.swift | 11 +---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 09006fd..4994208 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -55,6 +55,14 @@ 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 } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index bba2d60..d758801 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -196,7 +196,7 @@ struct ProfileView: View { Spacer() - Text(quarterTitle) + Text(viewModel.quarterTitle) .font(.subheadline) .foregroundStyle(.secondary) @@ -211,15 +211,6 @@ struct ProfileView: View { } } - private var quarterTitle: String { - guard let start = viewModel.state.selectedQuarterStart else { return "" } - let calendar = Calendar.current - let year = calendar.component(.year, from: start) - let month = calendar.component(.month, from: start) - let quarter = ((month - 1) / 3) + 1 - return "\(year) Q\(quarter)" - } - @ViewBuilder private func selectedDayDetailSection(for day: ProfileCompletionDay) -> some View { let activities = viewModel.selectedDayActivities From a20eea8019a80b07550437183abdcd3ca0d47066 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 12:16:02 +0900 Subject: [PATCH 22/28] =?UTF-8?q?fix:=20=ED=99=9C=EB=8F=99=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=88=EB=8D=94=EB=9D=BC=EB=8F=84=20=EB=82=B4=EB=A6=BC?= =?UTF-8?q?=EC=97=90=20=EC=9D=98=ED=95=B4=200=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EB=90=98=EC=96=B4=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=95=ED=83=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Profile/ProfileHeatmapView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index 52b1441..83aa92a 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -155,6 +155,6 @@ private struct MonthCompactHeatmapView: View { private func opacity(for count: Int, max: Int) -> Double { guard 0 < count && 0 < max else { return 0 } let ratio = Double(count) / Double(max) - return floor(ratio * 10) / 10 + return ceil(ratio * 10) / 10 } } From 033e5fefbfdee6f9b6ca776de735dbf88e737080 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 12:18:52 +0900 Subject: [PATCH 23/28] =?UTF-8?q?refactor:=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A9=94=EC=84=9C=EB=93=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 4994208..ba53cf3 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -94,21 +94,11 @@ final class ProfileViewModel: Store { } var canMoveToPreviousQuarter: Bool { - guard let selectedQuarterStart = state.selectedQuarterStart else { return false } - guard let previousQuarterStart = calendar.date(byAdding: .month, value: -3, to: selectedQuarterStart) else { - return false - } - let today = calendar.startOfDay(for: Date()) - return canMove(to: previousQuarterStart, calendar: calendar, today: today) + canMoveToQuarter(offsetMonths: -3) } var canMoveToNextQuarter: Bool { - guard let selectedQuarterStart = state.selectedQuarterStart else { return false } - guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: selectedQuarterStart) else { - return false - } - let today = calendar.startOfDay(for: Date()) - return canMove(to: nextQuarterStart, calendar: calendar, today: today) + canMoveToQuarter(offsetMonths: 3) } init( @@ -245,6 +235,15 @@ final class ProfileViewModel: Store { } private extension ProfileViewModel { + func setAlert( + _ state: inout State, + isPresented: Bool + ) { + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." + state.showAlert = isPresented + } + func fetchQuarterTodos(from quarterStart: Date) async throws -> [Todo] { guard let nextQuarterStart = calendar.date(byAdding: .month, value: 3, to: quarterStart) else { return [] @@ -355,12 +354,14 @@ private extension ProfileViewModel { return calendar.date(from: components) ?? startOfMonth(for: date, calendar: calendar) } - func setAlert( - _ state: inout State, - isPresented: Bool - ) { - state.alertTitle = "오류" - state.alertMessage = "문제가 발생했습니다. 잠시 후 다시 시도해주세요." - state.showAlert = isPresented + func canMoveToQuarter(offsetMonths: Int) -> Bool { + guard let selectedQuarterStart = state.selectedQuarterStart else { return false } + guard let targetQuarterStart = calendar.date( + byAdding: .month, value: offsetMonths, to: selectedQuarterStart) + else { + return false + } + let today = calendar.startOfDay(for: Date()) + return canMove(to: targetQuarterStart, calendar: calendar, today: today) } } From 31b211aa38089012b22d6f428736455d8d750d3e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 12:31:02 +0900 Subject: [PATCH 24/28] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B5=9C=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/TodoService.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 065917e..5f34fd3 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -67,15 +67,12 @@ final class TodoService { ]) } - let snapshot: QuerySnapshot if let pageSize = query.pageSize { - snapshot = try await firestoreQuery - .limit(to: pageSize) - .getDocuments() - } else { - snapshot = try await firestoreQuery.getDocuments() + firestoreQuery = firestoreQuery.limit(to: pageSize) } + let snapshot = try await firestoreQuery.getDocuments() + let items = snapshot.documents.compactMap { makeResponse(from: $0) } let nextCursor: TodoCursorDTO? From c087c2b12ca9eaab39d6aad3cc377b6f8a6eeae2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 14:04:03 +0900 Subject: [PATCH 25/28] =?UTF-8?q?refactor:=20100=EA=B0=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=ED=98=95=ED=83=9C=EB=A5=BC=20=ED=86=B5?= =?UTF-8?q?=ED=95=B4=20=EB=AA=A8=EB=93=A0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Domain/Entity/TodoQuery.swift | 7 +- .../FetchTodosByDateRangeUseCaseImpl.swift | 3 +- DevLog/Infra/Service/TodoService.swift | 69 +++++++++++++------ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/DevLog/Domain/Entity/TodoQuery.swift b/DevLog/Domain/Entity/TodoQuery.swift index 6bc4f55..934be89 100644 --- a/DevLog/Domain/Entity/TodoQuery.swift +++ b/DevLog/Domain/Entity/TodoQuery.swift @@ -14,7 +14,8 @@ struct TodoQuery { let createdAtFrom: Date? let createdAtTo: Date? let createdAtDescending: Bool - let pageSize: Int? + let pageSize: Int + let fetchAllPages: Bool init( kind: TodoKind? = nil, @@ -23,7 +24,8 @@ struct TodoQuery { createdAtFrom: Date? = nil, createdAtTo: Date? = nil, createdAtDescending: Bool = true, - pageSize: Int? = 20 + pageSize: Int = 20, + fetchAllPages: Bool = false ) { self.kind = kind self.keyword = keyword @@ -32,5 +34,6 @@ struct TodoQuery { self.createdAtTo = createdAtTo self.createdAtDescending = createdAtDescending self.pageSize = pageSize + self.fetchAllPages = fetchAllPages } } diff --git a/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift index 181d357..76418d9 100644 --- a/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift +++ b/DevLog/Domain/UseCase/UserData/Fetch/Todo/FetchTodosByDateRangeUseCaseImpl.swift @@ -18,7 +18,8 @@ final class FetchTodosByDateRangeUseCaseImpl: FetchTodosByDateRangeUseCase { let query = TodoQuery( createdAtFrom: startDate, createdAtTo: endDate, - pageSize: nil + pageSize: 100, + fetchAllPages: true ) let page = try await repository.fetchTodos(query, cursor: nil) return page.items diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 5f34fd3..801e617 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -27,7 +27,8 @@ final class TodoService { query.isPinned != nil ? "pinned=\(query.isPinned!)" : nil, query.createdAtFrom != nil ? "createdAtFrom=\(query.createdAtFrom!)" : nil, query.createdAtTo != nil ? "createdAtTo=\(query.createdAtTo!)" : nil, - query.pageSize != nil ? "pageSize=\(query.pageSize!)" : nil, + "pageSize=\(query.pageSize)", + query.fetchAllPages ? "fetchAllPages=true" : nil, cursor != nil ? "cursor=\(cursor!)" : nil ] logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") @@ -60,6 +61,38 @@ final class TodoService { } if trimmedKeyword.isEmpty { + if query.fetchAllPages { + var allItems: [TodoResponse] = [] + var pageCursor = cursor + + while true { + var pageQuery = firestoreQuery + if let pageCursor { + pageQuery = pageQuery.start(after: [ + Timestamp(date: pageCursor.createdAt), + pageCursor.documentID + ]) + } + + pageQuery = pageQuery.limit(to: query.pageSize) + let snapshot = try await pageQuery.getDocuments() + allItems.append(contentsOf: snapshot.documents.compactMap { makeResponse(from: $0) }) + + guard snapshot.documents.count == query.pageSize else { + break + } + + guard let lastDocument = snapshot.documents.last, + let nextCursor = makeCursor(from: lastDocument) else { + break + } + + pageCursor = nextCursor + } + + return TodoPageResponse(items: allItems, nextCursor: nil) + } + if let cursor { firestoreQuery = firestoreQuery.start(after: [ Timestamp(date: cursor.createdAt), @@ -67,29 +100,10 @@ final class TodoService { ]) } - if let pageSize = query.pageSize { - firestoreQuery = firestoreQuery.limit(to: pageSize) - } - + firestoreQuery = firestoreQuery.limit(to: query.pageSize) let snapshot = try await firestoreQuery.getDocuments() - let items = snapshot.documents.compactMap { makeResponse(from: $0) } - - let nextCursor: TodoCursorDTO? - if query.pageSize == nil { - nextCursor = nil - } else { - nextCursor = snapshot.documents.last.flatMap { document in - guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else { - return nil - } - - return TodoCursorDTO( - createdAt: createdAt.dateValue(), - documentID: document.documentID - ) - } - } + let nextCursor = snapshot.documents.last.flatMap { makeCursor(from: $0) } return TodoPageResponse(items: items, nextCursor: nextCursor) } @@ -167,6 +181,17 @@ final class TodoService { } private extension TodoService { + func makeCursor(from document: QueryDocumentSnapshot) -> TodoCursorDTO? { + guard let createdAt = document.data()[TodoFieldKey.createdAt.rawValue] as? Timestamp else { + return nil + } + + return TodoCursorDTO( + createdAt: createdAt.dateValue(), + documentID: document.documentID + ) + } + func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { makeResponse(documentID: snapshot.documentID, data: snapshot.data()) } From 65a49d17d358ce1fffc43598aff2bd2188421781 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 15:15:36 +0900 Subject: [PATCH 26/28] =?UTF-8?q?refactor:=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=ED=9B=84=20=EB=94=95=EC=85=94=EB=84=88?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=B4=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=ED=95=B4=20=EB=82=A0=EC=A7=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/ProfileViewModel.swift | 87 ++++++++++++------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index ba53cf3..666ef9a 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -14,8 +14,8 @@ final class ProfileViewModel: Store { var statusMessage: String = "" var avatarURL: URL? var selectedQuarterStart: Date? - var completionQuarterCache: [Date: ProfileCompletionQuarter] = [:] - var quarterTodosCache: [Date: [Todo]] = [:] + var completionQuarter: ProfileCompletionQuarter? + var dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] var selectedActivityTypes: Set = [.created, .completed] var selectedDay: ProfileCompletionDay? var selectedActivityForSheet: ProfileSelectedDayActivity? @@ -31,7 +31,11 @@ final class ProfileViewModel: Store { case tapResetStatusMessageButton case willUpdateStatusMessage case fetchUserData(UserProfile) - case setCompletionQuarter(ProfileCompletionQuarter, [Todo]) + case setCompletionQuarter( + quarterStart: Date, + quarter: ProfileCompletionQuarter, + dayActivitiesByDate: [Date: [ProfileSelectedDayActivity]] + ) case moveQuarter(Int) case toggleActivityType(ProfileActivityType) case selectDay(ProfileCompletionDay?) @@ -68,28 +72,17 @@ final class ProfileViewModel: Store { } var selectedQuarter: ProfileCompletionQuarter? { - guard let selectedQuarterStart = state.selectedQuarterStart else { return nil } - return state.completionQuarterCache[selectedQuarterStart] + state.completionQuarter } var selectedDayActivities: [ProfileSelectedDayActivity] { - guard let selectedDay = state.selectedDay, - let selectedQuarterStart = state.selectedQuarterStart, - let todos = state.quarterTodosCache[selectedQuarterStart] else { return [] } + guard let selectedDay = state.selectedDay else { return [] } let dayStart = calendar.startOfDay(for: selectedDay.date) + let activities = state.dayActivitiesByDate[dayStart] ?? [] - return todos.compactMap { todo in - let isCreated = state.selectedActivityTypes.contains(.created) - && calendar.startOfDay(for: todo.createdAt) == dayStart - let isCompleted = state.selectedActivityTypes.contains(.completed) - && todo.isCompleted - && calendar.startOfDay(for: todo.updatedAt) == dayStart - guard isCreated || isCompleted else { return nil } - return ProfileSelectedDayActivity( - todo: todo, - showsCreated: isCreated, - showsCompleted: isCompleted - ) + return activities.filter { activity in + (state.selectedActivityTypes.contains(.created) && activity.showsCreated) + || (state.selectedActivityTypes.contains(.completed) && activity.showsCompleted) } } @@ -130,8 +123,7 @@ final class ProfileViewModel: Store { state.selectedActivityTypes = settings } effects = [.fetchUserData] - if let selectedQuarterStart = state.selectedQuarterStart, - state.completionQuarterCache[selectedQuarterStart] == nil { + if let selectedQuarterStart = state.selectedQuarterStart { effects.append(.fetchCompletionQuarter(selectedQuarterStart)) } case .setAlert(let isPresented): @@ -143,9 +135,10 @@ final class ProfileViewModel: Store { state.email = profile.email state.statusMessage = profile.statusMessage state.avatarURL = profile.avatarURL - case .setCompletionQuarter(let quarter, let todos): - state.completionQuarterCache[quarter.quarterStart] = quarter - state.quarterTodosCache[quarter.quarterStart] = todos + case .setCompletionQuarter(let quarterStart, let quarter, let dayActivitiesByDate): + guard state.selectedQuarterStart == quarterStart else { break } + state.completionQuarter = quarter + state.dayActivitiesByDate = dayActivitiesByDate case .selectDay(let day): if let day, state.selectedDay?.date == day.date { state.selectedDay = nil @@ -166,11 +159,11 @@ final class ProfileViewModel: Store { guard canMove(to: nextQuarterStart, calendar: calendar, today: today) else { break } state.selectedQuarterStart = nextQuarterStart + state.completionQuarter = nil + state.dayActivitiesByDate = [:] state.selectedDay = nil state.selectedActivityForSheet = nil - if state.completionQuarterCache[nextQuarterStart] == nil { - effects = [.fetchCompletionQuarter(nextQuarterStart)] - } + effects = [.fetchCompletionQuarter(nextQuarterStart)] case .toggleActivityType(let activityType): if state.selectedActivityTypes.contains(activityType), state.selectedActivityTypes.count == 1 { break @@ -212,7 +205,14 @@ final class ProfileViewModel: Store { let todos = try await fetchQuarterTodos(from: quarterStart) let months = makeCompletionMonths(from: todos, quarterStart: quarterStart) let quarter = ProfileCompletionQuarter(quarterStart: quarterStart, months: months) - send(.setCompletionQuarter(quarter, todos)) + let dayActivitiesByDate = makeDayActivitiesByDate(from: todos) + send( + .setCompletionQuarter( + quarterStart: quarterStart, + quarter: quarter, + dayActivitiesByDate: dayActivitiesByDate + ) + ) } catch { send(.setAlert(true)) } @@ -235,6 +235,35 @@ final class ProfileViewModel: Store { } private extension ProfileViewModel { + func makeDayActivitiesByDate(from todos: [Todo]) -> [Date: [ProfileSelectedDayActivity]] { + var activitiesByDate: [Date: [ProfileSelectedDayActivity]] = [:] + + for todo in todos { + let createdDay = calendar.startOfDay(for: todo.createdAt) + let completedDay = todo.isCompleted ? calendar.startOfDay(for: todo.updatedAt) : nil + + activitiesByDate[createdDay, default: []].append( + ProfileSelectedDayActivity( + todo: todo, + showsCreated: true, + showsCompleted: completedDay == createdDay + ) + ) + + if let completedDay, completedDay != createdDay { + activitiesByDate[completedDay, default: []].append( + ProfileSelectedDayActivity( + todo: todo, + showsCreated: false, + showsCompleted: true + ) + ) + } + } + + return activitiesByDate + } + func setAlert( _ state: inout State, isPresented: Bool From 49bfcb810980469654271f8c7c3826931a5fb04b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 15:53:46 +0900 Subject: [PATCH 27/28] =?UTF-8?q?refactor:=20=EC=A2=80=20=EB=8D=94=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/ProfileViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index 666ef9a..b153679 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -115,7 +115,8 @@ final class ProfileViewModel: Store { switch action { case .onAppear: if state.selectedQuarterStart == nil { - state.selectedQuarterStart = quarterStart(for: Date(), calendar: calendar) + guard let quarterStart = quarterStart(for: Date(), calendar: calendar) else { break } + state.selectedQuarterStart = quarterStart } let rawValues = fetchHeatmapActivityTypesUseCase.execute() let settings = normalizeActivityTypes(rawValues) @@ -374,13 +375,13 @@ private extension ProfileViewModel { return monthInterval.start } - func quarterStart(for date: Date, calendar: Calendar) -> Date { + func quarterStart(for date: Date, calendar: Calendar) -> Date? { let month = calendar.component(.month, from: date) let startMonth = ((month - 1) / 3) * 3 + 1 var components = calendar.dateComponents([.year], from: date) components.month = startMonth components.day = 1 - return calendar.date(from: components) ?? startOfMonth(for: date, calendar: calendar) + return calendar.date(from: components) } func canMoveToQuarter(offsetMonths: Int) -> Bool { From c1c61363034f8c63ff8afd475d390b7e0f97c43b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 2 Mar 2026 18:17:37 +0900 Subject: [PATCH 28/28] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Presentation/ViewModel/ProfileViewModel.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index b153679..e551d87 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -368,13 +368,6 @@ private extension ProfileViewModel { return ProfileCompletionMonth(monthStart: monthStart, weeks: weeks) } - func startOfMonth(for date: Date, calendar: Calendar) -> Date { - guard let monthInterval = calendar.dateInterval(of: .month, for: date) else { - return calendar.startOfDay(for: date) - } - return monthInterval.start - } - func quarterStart(for date: Date, calendar: Calendar) -> Date? { let month = calendar.component(.month, from: date) let startMonth = ((month - 1) / 3) * 3 + 1