diff --git a/DevLog/Data/DTO/TodoDTO.swift b/DevLog/Data/DTO/TodoDTO.swift index 4402e02..d75f6da 100644 --- a/DevLog/Data/DTO/TodoDTO.swift +++ b/DevLog/Data/DTO/TodoDTO.swift @@ -16,6 +16,7 @@ struct TodoRequest: Encodable { let content: String let createdAt: Date let updatedAt: Date + let completedAt: Date? let dueDate: Date? let tags: [String] let kind: TodoKind @@ -31,6 +32,7 @@ struct TodoResponse { let content: String let createdAt: Date let updatedAt: Date + let completedAt: Date? let dueDate: Date? let tags: [String] let kind: String diff --git a/DevLog/Data/Mapper/TodoMapping.swift b/DevLog/Data/Mapper/TodoMapping.swift index 9cb8dbf..1b5888b 100644 --- a/DevLog/Data/Mapper/TodoMapping.swift +++ b/DevLog/Data/Mapper/TodoMapping.swift @@ -16,6 +16,7 @@ extension TodoRequest { content: entity.content, createdAt: entity.createdAt, updatedAt: entity.updatedAt, + completedAt: entity.completedAt, dueDate: entity.dueDate, tags: entity.tags, kind: entity.kind @@ -38,6 +39,7 @@ extension TodoResponse { content: self.content, createdAt: self.createdAt, updatedAt: self.updatedAt, + completedAt: self.completedAt, dueDate: self.dueDate, tags: self.tags, kind: kind diff --git a/DevLog/Domain/Entity/Todo.swift b/DevLog/Domain/Entity/Todo.swift index e6fdd21..3430618 100644 --- a/DevLog/Domain/Entity/Todo.swift +++ b/DevLog/Domain/Entity/Todo.swift @@ -16,6 +16,7 @@ struct Todo: Identifiable, Hashable { var content: String // 할 일의 설명 var createdAt: Date // 할 일 생성 날짜 var updatedAt: Date // 할 일 수정 날짜 + var completedAt: Date? // 할 일 완료 날짜 var dueDate: Date? // 할 일의 마감 날짜 (선택 사항) var tags: [String] // 할 일에 연결된 태그들 var kind: TodoKind // 할 일의 종류 diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 801e617..3ec1044 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -130,6 +130,9 @@ final class TodoService { let docRef = collection.document(request.id) var data = try encoder.encode(request) data.removeValue(forKey: TodoFieldKey.id.rawValue) + if request.completedAt == nil { + data[TodoFieldKey.completedAt.rawValue] = NSNull() + } if request.dueDate == nil { data[TodoFieldKey.dueDate.rawValue] = NSNull() } @@ -217,6 +220,7 @@ private extension TodoService { return nil } + let completedAt = (data[TodoFieldKey.completedAt.rawValue] as? Timestamp)?.dateValue() let dueDate = (data[TodoFieldKey.dueDate.rawValue] as? Timestamp)?.dateValue() return TodoResponse( id: documentID, @@ -227,6 +231,7 @@ private extension TodoService { content: content, createdAt: createdAtTimestamp.dateValue(), updatedAt: updatedAtTimestamp.dateValue(), + completedAt: completedAt, dueDate: dueDate, tags: tags, kind: kind @@ -242,6 +247,7 @@ private extension TodoService { case content case createdAt case updatedAt + case completedAt case dueDate case tags case kind diff --git a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift index c8590c4..8cc690a 100644 --- a/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift +++ b/DevLog/Presentation/Structure/Profile/ProfileCompletionQuarter.swift @@ -8,8 +8,15 @@ import Foundation struct ProfileCompletionQuarter: Identifiable, Hashable { + var id: Date { quarterStart } let quarterStart: Date let months: [ProfileCompletionMonth] - - var id: Date { quarterStart } + var maxCount: Int { + months + .flatMap { $0.weeks } + .flatMap { $0 } + .filter { $0.isInMonth } + .map { $0.createdCount + $0.completedCount } + .max() ?? 0 + } } diff --git a/DevLog/Presentation/Structure/TodoListItem.swift b/DevLog/Presentation/Structure/TodoListItem.swift index ac00d9f..9bfc1eb 100644 --- a/DevLog/Presentation/Structure/TodoListItem.swift +++ b/DevLog/Presentation/Structure/TodoListItem.swift @@ -12,11 +12,13 @@ struct TodoListItem: Identifiable, Hashable { let title: String let tags: [String] let isPinned: Bool + let isCompleted: Bool init(from todo: Todo) { self.id = todo.id self.title = todo.title self.tags = todo.tags self.isPinned = todo.isPinned + self.isCompleted = todo.isCompleted } } diff --git a/DevLog/Presentation/ViewModel/ProfileViewModel.swift b/DevLog/Presentation/ViewModel/ProfileViewModel.swift index e551d87..9a76a20 100644 --- a/DevLog/Presentation/ViewModel/ProfileViewModel.swift +++ b/DevLog/Presentation/ViewModel/ProfileViewModel.swift @@ -241,7 +241,7 @@ private extension ProfileViewModel { for todo in todos { let createdDay = calendar.startOfDay(for: todo.createdAt) - let completedDay = todo.isCompleted ? calendar.startOfDay(for: todo.updatedAt) : nil + let completedDay = todo.completedAt.map { calendar.startOfDay(for: $0) } activitiesByDate[createdDay, default: []].append( ProfileSelectedDayActivity( @@ -305,8 +305,8 @@ private extension ProfileViewModel { let createdDay = calendar.startOfDay(for: todo.createdAt) dailyCreatedCount[createdDay, default: 0] += 1 - if todo.isCompleted { - let completedDay = calendar.startOfDay(for: todo.updatedAt) + if let completedAt = todo.completedAt { + let completedDay = calendar.startOfDay(for: completedAt) dailyCompletedCount[completedDay, default: 0] += 1 } } diff --git a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift index f5c0ae5..f1dd17c 100644 --- a/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoEditorViewModel.swift @@ -49,6 +49,7 @@ final class TodoEditorViewModel: Store { private let isCompleted: Bool private let isChecked: Bool private let createdAt: Date? + private let completedAt: Date? private let kind: TodoKind // 새로운 Todo 생성용 생성자 @@ -59,6 +60,7 @@ final class TodoEditorViewModel: Store { self.isCompleted = false self.isChecked = false self.createdAt = nil + self.completedAt = nil self.kind = kind } @@ -70,6 +72,7 @@ final class TodoEditorViewModel: Store { self.isCompleted = todo.isCompleted self.isChecked = todo.isChecked self.createdAt = todo.createdAt + self.completedAt = todo.completedAt self.kind = todo.kind state.title = todo.title state.content = todo.content @@ -141,6 +144,7 @@ extension TodoEditorViewModel { content: state.content, createdAt: self.createdAt ?? date, updatedAt: date, + completedAt: self.completedAt, dueDate: state.dueDate, tags: state.tags.map { $0 }, kind: self.kind diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 0da5a01..a565364 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -37,6 +37,7 @@ final class TodoListViewModel: Store { case setShowEditor(Bool) case swipeTodo(TodoListItem) case tapFilterOption(FilterOption) + case tapToggleCompleted(TodoListItem) case tapTogglePinned(TodoListItem) case undoDelete @@ -50,6 +51,7 @@ final class TodoListViewModel: Store { case upsertTodo(Todo) // Run + case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) case setLoading(Bool) case appendTodos([TodoListItem], nextCursor: TodoCursor?) @@ -62,6 +64,7 @@ final class TodoListViewModel: Store { case loadNextPage case upsert(Todo) case delete(String) + case toggleCompleted(TodoListItem) case togglePinned(TodoListItem) } @@ -91,13 +94,13 @@ final class TodoListViewModel: Store { var effects: [SideEffect] = [] switch action { - case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapTogglePinned, .undoDelete: + case .refresh, .setAlert, .setShowEditor, .swipeTodo, .tapFilterOption, .tapToggleCompleted, .tapTogglePinned, .undoDelete: effects = reduceByUser(action, state: &state) case .confirmDelete, .onAppear, .loadNextPage, .setScope, .setSearchText, .setToast, .upsertTodo: effects = reduceByView(action, state: &state) - case .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore: + case .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -145,6 +148,22 @@ final class TodoListViewModel: Store { send(.setAlert(true)) } } + case .toggleCompleted(let item): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + var todo = try await fetchTodoByIDUseCase.execute(item.id) + let now = Date() + todo.isCompleted.toggle() + todo.completedAt = todo.isCompleted ? now : nil + todo.updatedAt = now + try await upsertTodoUseCase.execute(todo) + send(.didToggleCompleted(TodoListItem(from: todo))) + } catch { + send(.setAlert(true)) + } + } case .togglePinned(let item): Task { do { @@ -195,6 +214,8 @@ private extension TodoListViewModel { return effects case .tapFilterOption(let option): state.filterOption = option + case .tapToggleCompleted(let todo): + return [.toggleCompleted(todo)] case .tapTogglePinned(let todo): return [.togglePinned(todo)] case .undoDelete: @@ -238,6 +259,10 @@ private extension TodoListViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { + case .didToggleCompleted(let todo): + if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { + state.todos[index] = todo + } case .didTogglePinned(let todo): if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo diff --git a/DevLog/UI/Common/Component/TodoItemRow.swift b/DevLog/UI/Common/Component/TodoItemRow.swift index c3681a6..ad6de7d 100644 --- a/DevLog/UI/Common/Component/TodoItemRow.swift +++ b/DevLog/UI/Common/Component/TodoItemRow.swift @@ -23,6 +23,11 @@ struct TodoItemRow: View { .font(.headline) .foregroundStyle(.orange) } + if item.isCompleted { + Image(systemName: "checkmark.circle.fill") + .font(.headline) + .foregroundStyle(.green) + } Text(item.title) .font(.headline) .foregroundStyle(Color(.label)) diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 6d736e1..6869037 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -47,6 +47,12 @@ struct TodoListView: View { Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") } .tint(Color.orange) + Button { + viewModel.send(.tapToggleCompleted(todo)) + } label: { + Image(systemName: todo.isCompleted ? "arrow.uturn.backward" : "checkmark") + } + .tint(Color.green) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive, action: { @@ -54,7 +60,6 @@ struct TodoListView: View { }) { Image(systemName: "trash") } - } } .listStyle(.plain) diff --git a/DevLog/UI/Profile/ProfileHeatmapView.swift b/DevLog/UI/Profile/ProfileHeatmapView.swift index 83aa92a..332b7c4 100644 --- a/DevLog/UI/Profile/ProfileHeatmapView.swift +++ b/DevLog/UI/Profile/ProfileHeatmapView.swift @@ -21,6 +21,7 @@ struct ProfileHeatmapView: View { ForEach(Array(zip(months.indices, months)), id: \.1) { index, month in MonthCompactHeatmapView( month: month, + maxCount: quarter.maxCount, selectedActivityTypes: selectedActivityTypes, selectedDay: selectedDay, onSelectDay: onSelectDay @@ -64,6 +65,7 @@ struct ProfileHeatmapView: View { private struct MonthCompactHeatmapView: View { @Environment(\.colorScheme) private var colorScheme let month: ProfileCompletionMonth + let maxCount: Int let selectedActivityTypes: Set let selectedDay: ProfileCompletionDay? let onSelectDay: (ProfileCompletionDay) -> Void @@ -72,12 +74,6 @@ 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) diff --git a/DevLog/UI/Setting/PushNotificationSettingsView.swift b/DevLog/UI/Setting/PushNotificationSettingsView.swift index 41e8c74..32898b5 100644 --- a/DevLog/UI/Setting/PushNotificationSettingsView.swift +++ b/DevLog/UI/Setting/PushNotificationSettingsView.swift @@ -20,6 +20,7 @@ struct PushNotificationSettingsView: View { )) { Text("푸시 알람 활성화") } + .tint(.blue) }, footer: { Text("설정에서의 푸시 알람 설정과 별개입니다.") })